From f0f8c84d1b7cbc739b4e05904d004f3ea3322945 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 17:58:47 -0500 Subject: [PATCH 001/610] feat(cli): make hermes serve a real headless backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `serve` (added in #54568) reused cmd_dashboard wholesale, so it still behaved like a dashboard: it ran a full vite build every launch, mounted and served the SPA whenever a stray web_dist/ existed, printed "Hermes Web UI →", and announced HERMES_DASHBOARD_READY. It's the headless JSON-RPC/WS backend the desktop app and remote clients run — pure socket clients that never load the browser SPA. Mark serve with headless_backend=True (resolved once in cmd_dashboard) and: - skip _build_web_ui entirely on the serve path - export HERMES_SERVE_HEADLESS=1 so mount_spa() disables the SPA even when a dist is present — only the JSON-RPC/WS/API surface is reachable - announce the bind ("Hermes backend listening on host:port") instead of a browser/auth-gated URL - print a neutral HERMES_BACKEND_READY sentinel; dashboard keeps the legacy one and the desktop port-discovery regex matches either - preserve serve across the named-profile re-exec so it can't rebuild as dashboard `hermes dashboard` is unchanged (builds + serves the browser UI). Backward compatible: old apps only ever spawn dashboard (legacy token + UI intact) and never invoke serve; the ready-file side channel is name-agnostic. The one behavior change is that a remote `hermes serve` no longer serves the browser dashboard as a side effect — that's `hermes dashboard`'s job. Tests: serve headless_backend contract, SPA-disabled-with-dist, the HERMES_BACKEND_READY desktop parse (17/17 node), and the existing serve/dashboard/web_server suites. AGENTS.md documents the behavior. --- AGENTS.md | 2 +- apps/desktop/electron/backend-ready.cjs | 9 +++-- apps/desktop/electron/backend-ready.test.cjs | 7 ++++ hermes_cli/main.py | 16 +++++++-- hermes_cli/subcommands/dashboard.py | 11 ++++-- hermes_cli/web_server.py | 36 ++++++++++++++++---- tests/hermes_cli/test_serve_command.py | 7 ++++ tests/hermes_cli/test_web_server.py | 21 ++++++++++++ 8 files changed, 93 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e89c819844e6..e1dbaaa5c43c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -491,7 +491,7 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes ### Electron Desktop Chat App (`apps/desktop/`) -A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`. +A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`. **Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline: diff --git a/apps/desktop/electron/backend-ready.cjs b/apps/desktop/electron/backend-ready.cjs index 016572bec91c..ef2ee27ef9a9 100644 --- a/apps/desktop/electron/backend-ready.cjs +++ b/apps/desktop/electron/backend-ready.cjs @@ -1,6 +1,9 @@ const fs = require('node:fs') -const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m +// `hermes serve` announces HERMES_BACKEND_READY; the legacy `hermes dashboard` +// backend announces HERMES_DASHBOARD_READY. Accept either so the desktop spawn +// works against both the headless backend and old/dashboard runtimes. +const _READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/m // The announcement clock starts the instant the backend process is spawned — // before uvicorn binds its socket. On a cold install the child must first @@ -30,8 +33,8 @@ function resolvePortAnnounceTimeoutMs(env = process.env) { } /** - * Watch a child process's stdout for the `HERMES_DASHBOARD_READY port=` - * line that web_server.py prints after uvicorn binds its socket. + * Watch a child process's stdout for the `HERMES_(BACKEND|DASHBOARD)_READY + * port=` line that web_server.py prints after uvicorn binds its socket. * * Returns the parsed port. Rejects if: * - the child exits before emitting the line diff --git a/apps/desktop/electron/backend-ready.test.cjs b/apps/desktop/electron/backend-ready.test.cjs index 2792baf371ab..d89b7f1a2d88 100644 --- a/apps/desktop/electron/backend-ready.test.cjs +++ b/apps/desktop/electron/backend-ready.test.cjs @@ -86,6 +86,13 @@ test('resolves with the announced port', async () => { assert.equal(await p, 54321) }) +test('resolves with a HERMES_BACKEND_READY port (headless `serve`)', async () => { + const child = makeFakeChild() + const p = waitForDashboardPort(child, 1000) + child.stdout.emit('data', 'HERMES_BACKEND_READY port=43210\n') + assert.equal(await p, 43210) +}) + test('parses the port even when the line arrives split across chunks', async () => { const child = makeFakeChild() const p = waitForDashboardPort(child, 1000) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index b71c59f38353..300e5bc9a347 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11648,6 +11648,11 @@ def cmd_dashboard(args): remaining = _find_stale_dashboard_pids() sys.exit(1 if remaining else 0) + # `serve` is the headless backend: no UI build, no SPA mount, neutral + # ready sentinel. Resolved once and threaded through the re-exec, the + # build gate, and start_server. + _headless_backend = getattr(args, "headless_backend", False) + # ── Unified profile launch routing ──────────────────────────────── # The dashboard is a MACHINE management surface: it can read/write any # profile via the per-request ?profile= scoping. Running one dashboard @@ -11693,7 +11698,9 @@ def cmd_dashboard(args): reexec_argv = [ sys.executable, "-m", "hermes_cli.main", "-p", "default", - "dashboard", + # Preserve the lean serve path across the re-exec so a named-profile + # `serve` doesn't silently rebuild the UI as `dashboard`. + "serve" if _headless_backend else "dashboard", "--port", str(args.port), "--host", args.host, "--open-profile", _launch_profile, @@ -11762,7 +11769,11 @@ def cmd_dashboard(args): # backend is the desktop's primary entrypoint and needs the same. _sync_bundled_skills_quietly() - if "HERMES_WEB_DIST" not in os.environ and not getattr(args, "skip_build", False): + if _headless_backend: + # Don't build the SPA, and tell mount_spa() (read at web_server import + # below) to disable it even if a stray dist exists. Set it first. + os.environ["HERMES_SERVE_HEADLESS"] = "1" + elif "HERMES_WEB_DIST" not in os.environ and not getattr(args, "skip_build", False): if not _build_web_ui(PROJECT_ROOT / "web", fatal=True): sys.exit(1) elif getattr(args, "skip_build", False): @@ -11835,6 +11846,7 @@ def cmd_dashboard(args): open_browser=not args.no_open, allow_public=getattr(args, "insecure", False), initial_profile=getattr(args, "open_profile", "") or "", + headless=_headless_backend, ) diff --git a/hermes_cli/subcommands/dashboard.py b/hermes_cli/subcommands/dashboard.py index bea3c2244dec..a345a9d9d599 100644 --- a/hermes_cli/subcommands/dashboard.py +++ b/hermes_cli/subcommands/dashboard.py @@ -1,8 +1,9 @@ """``hermes dashboard`` / ``hermes serve`` subcommand parsers. ``dashboard`` is the browser web UI; ``serve`` is the same gateway, headless — -what the desktop app and remote backends run. Both share one handler -(``cmd_dashboard`` → ``start_server``). Extracted from +what the desktop app and remote backends run. ``serve`` also skips the web UI +build (``headless_backend=True``): pure JSON-RPC/WS clients never load the SPA. +Both share one handler (``cmd_dashboard`` → ``start_server``). Extracted from ``hermes_cli/main.py:main()`` (god-file Phase 2); handler injected to avoid importing ``main``. """ @@ -148,7 +149,11 @@ def build_dashboard_parser( serve_parser.add_argument( "--no-open", action="store_true", help=argparse.SUPPRESS ) - serve_parser.set_defaults(func=cmd_dashboard, no_open=True) + # `headless_backend` marks the lean path: desktop/remote clients speak pure + # JSON-RPC/WS, so `serve` skips the web UI build AND never serves the SPA + # (cmd_dashboard exports HERMES_SERVE_HEADLESS=1). `dashboard` leaves it + # unset and serves the browser UI as before. + serve_parser.set_defaults(func=cmd_dashboard, no_open=True, headless_backend=True) # `hermes dashboard register` — register a self-hosted dashboard OAuth # client with Nous Portal and write the client_id into ~/.hermes/.env. diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index e01f925b3326..805455cae932 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -12837,13 +12837,21 @@ def mount_spa(application: FastAPI): and the SPA's runtime ``__HERMES_BASE_PATH__`` honour that prefix without rebuilding the bundle. """ - if not WEB_DIST.exists(): + # `hermes serve` is the headless backend: it must NEVER serve the browser + # SPA, even if a dist is lying around from a prior `dashboard`/build. Take + # the no-frontend path so only the JSON-RPC/WS/API surface is reachable. + _headless = os.environ.get("HERMES_SERVE_HEADLESS") == "1" + if _headless or not WEB_DIST.exists(): + _msg = ( + "Headless backend (hermes serve): web UI disabled — use " + "`hermes dashboard` for the browser UI." + if _headless + else "Frontend not built. Run: cd web && npm run build" + ) + @application.get("/{full_path:path}") async def no_frontend(full_path: str): - return JSONResponse( - {"error": "Frontend not built. Run: cd web && npm run build"}, - status_code=404, - ) + return JSONResponse({"error": _msg}, status_code=404) return _index_path = WEB_DIST / "index.html" @@ -13998,6 +14006,7 @@ def start_server( open_browser: bool = True, allow_public: bool = False, initial_profile: str = "", + headless: bool = False, ): """Start the web UI server. @@ -14005,6 +14014,10 @@ def start_server( URL as ``?profile=`` so the SPA's profile switcher preselects it — used when a profile alias (`` dashboard``) routes to the machine dashboard. + + ``headless`` is the ``serve`` path: the JSON-RPC/WS backend with no UI + build and no SPA mount (mount_spa() honours ``HERMES_SERVE_HEADLESS``), so + the banner announces the bind rather than a browser URL. """ import uvicorn @@ -14150,8 +14163,17 @@ def start_server( app.state.bound_port = actual_port _write_dashboard_ready_file(actual_port) - print(f"HERMES_DASHBOARD_READY port={actual_port}", flush=True) - print(f" Hermes Web UI → http://{host}:{actual_port}") + # Port-discovery sentinel parsed by the desktop spawn. `serve` is a + # plain backend, not a dashboard, so it announces a neutral token; + # `dashboard` keeps the legacy one. The desktop matches either. + ready_token = "HERMES_BACKEND_READY" if headless else "HERMES_DASHBOARD_READY" + print(f"{ready_token} port={actual_port}", flush=True) + if headless: + # No SPA, and the JSON-RPC/WS endpoints are auth-gated — don't + # advertise a paste-and-connect URL, just announce the bind. + print(f" Hermes backend listening on {host}:{actual_port}") + else: + print(f" Hermes Web UI → http://{host}:{actual_port}") _maybe_open_browser(host, actual_port, open_browser, initial_profile) # Collapse the peer-hangup teardown flood (#50005). When the Desktop diff --git a/tests/hermes_cli/test_serve_command.py b/tests/hermes_cli/test_serve_command.py index 19db4b823805..911b0db95834 100644 --- a/tests/hermes_cli/test_serve_command.py +++ b/tests/hermes_cli/test_serve_command.py @@ -61,3 +61,10 @@ def test_serve_takes_the_same_runtime_flags_as_dashboard(): def test_serve_supports_the_lifecycle_flags(): for flag in ("--stop", "--status"): assert getattr(_parser().parse_args(["serve", flag]), flag.lstrip("-")) is True + + +def test_serve_is_a_headless_backend_but_dashboard_is_not(): + # `headless_backend` is the flag cmd_dashboard reads to skip the web UI + # build; only `serve` carries it. + assert getattr(_parser().parse_args(["serve"]), "headless_backend", False) is True + assert getattr(_parser().parse_args(["dashboard"]), "headless_backend", False) is False diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 1b2caf952917..3087b9e9b4b9 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2509,6 +2509,27 @@ class TestWebServerEndpoints: assert seen_encodings == {"index": "utf-8", "css": "utf-8"} + def test_headless_serve_disables_spa_even_with_a_dist(self, monkeypatch, tmp_path): + """`hermes serve` (HERMES_SERVE_HEADLESS) must NOT serve the SPA even + when a built dist is present — only the API/WS surface is reachable.""" + from fastapi import FastAPI + from starlette.testclient import TestClient + import hermes_cli.web_server as ws + + dist = tmp_path / "web_dist" + (dist / "assets").mkdir(parents=True) + (dist / "index.html").write_text("UI", encoding="utf-8") + + monkeypatch.setattr(ws, "WEB_DIST", dist) + monkeypatch.setenv("HERMES_SERVE_HEADLESS", "1") + app_ = FastAPI() + ws.mount_spa(app_) + + for route in ("/", "/chat"): + resp = TestClient(app_).get(route) + assert resp.status_code == 404 + assert "web UI disabled" in resp.json()["error"] + def test_set_model_main_nous_applies_gateway_defaults(self, monkeypatch): """Switching the main provider to Nous calls apply_nous_managed_defaults (mirroring the CLI's post-model-selection Tool Gateway routing) and From 4a6751a2bccef3205c1a6b2810998481cfa6ed5b Mon Sep 17 00:00:00 2001 From: yoma Date: Sat, 4 Jul 2026 19:35:06 +0800 Subject: [PATCH 002/610] fix(desktop): preserve project cwd for new sessions --- .../hooks/use-session-actions.test.tsx | 21 +++++++++++++++++++ .../hooks/use-session-actions/index.ts | 5 ++--- 2 files changed, 23 insertions(+), 3 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 dc4dcd4176db..9f5da94557e9 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 @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getSessionMessages, type SessionInfo } from '@/hermes' import { createClientSessionState } from '@/lib/chat-runtime' import { $activeGatewayProfile, $newChatProfile } from '@/store/profile' +import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects' import { $activeSessionId, $currentCwd, @@ -113,6 +114,8 @@ describe('createBackendSessionForSend profile routing', () => { cleanup() $newChatProfile.set(null) $activeGatewayProfile.set('default') + $projectScope.set(ALL_PROJECTS) + $projectTree.set([]) $currentCwd.set('') vi.restoreAllMocks() }) @@ -155,6 +158,24 @@ describe('createBackendSessionForSend profile routing', () => { expect(params).toMatchObject({ cwd: '/remote/worktree' }) }) + + it('falls back to the entered project cwd when the current cwd is blank', async () => { + const params = await createWith(() => { + $projectTree.set([ + { + id: 'p_app', + label: 'App', + path: '/repo/app', + repos: [{ groups: [], id: '/repo/app', label: 'app', path: '/repo/app', sessionCount: 0 }], + sessionCount: 0 + } + ]) + $projectScope.set('p_app') + $currentCwd.set('') + }) + + expect(params).toMatchObject({ cwd: '/repo/app' }) + }) }) // ── Resume failure recovery (the "stuck loading session window" bug) ────────── diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 0f88fc948100..3d8a15255dbe 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -38,8 +38,7 @@ import { setSessionStartedAt, setSessionsTotal, setTurnStartedAt, - setYoloActive, - workspaceCwdForNewSession + setYoloActive } from '@/store/session' import { broadcastSessionsChanged } from '@/store/session-sync' import { isWatchWindow } from '@/store/windows' @@ -163,7 +162,7 @@ export function useSessionActions({ // a backend resolves its own launch profile to None (_profile_home). const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get()) await ensureGatewayProfile(newChatProfile) - const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession() + const cwd = $currentCwd.get().trim() || resolveNewSessionCwd() // The composer's model/effort/fast is sticky UI state ($currentModel, // $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it // with every session.create so the new chat opens on whatever the picker From 7dfd5077ceef4d5a6f7953c050bad1a75e86e215 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:10:51 -0700 Subject: [PATCH 003/610] feat(oneshot): add --usage-file JSON usage report to hermes -z (#59615) * feat(oneshot): add --usage-file JSON usage report to hermes -z Pipelines driving hermes -z (batch reviewers, cron scripts, eval harnesses) had no way to account for per-invocation spend: the agent computes estimated_cost_usd and full token counts internally, but oneshot mode discards everything except the final response text. - hermes -z PROMPT --usage-file PATH writes a JSON report after the run: estimated_cost_usd, cost_status/source, input/output/cache/ reasoning/total tokens, api_calls, model, provider, session_id, completed, failed. - Written even when the run fails (with a failure field) so callers can always account for spend; the write itself is best-effort and never masks the run's own outcome. - Flag registered in both the full parser and the Termux fast path; added to both value-flag scan sets so profile detection stays correct. Validation: 6 unit tests + live E2E (real -z run produced a report with real OpenRouter cost + token counts). * test: include usage_file kwarg in oneshot dispatch assertions The two dispatch tests assert the exact kwargs dict passed to run_oneshot; the new usage_file kwarg must appear there. --- hermes_cli/_parser.py | 11 ++++ hermes_cli/main.py | 4 ++ hermes_cli/oneshot.py | 47 ++++++++++++++ tests/hermes_cli/test_oneshot_usage_file.py | 69 +++++++++++++++++++++ tests/hermes_cli/test_tui_resume_flow.py | 2 + 5 files changed, 133 insertions(+) create mode 100644 tests/hermes_cli/test_oneshot_usage_file.py diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index d06a3d4ac9c1..b2be73871b25 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -112,6 +112,17 @@ def build_top_level_parser(): "auto-bypassed. Intended for scripts / pipes." ), ) + parser.add_argument( + "--usage-file", + metavar="PATH", + default=None, + help=( + "One-shot mode only: after the run, write a JSON usage report " + "(estimated cost, token counts, model, api_calls) to PATH. " + "The report is written even when the run fails, so pipelines " + "can always account for spend. No effect outside -z/--oneshot." + ), + ) # --model / --provider are accepted at the top level so they can pair # with -z without needing the `chat` subcommand. If neither -z nor a # subcommand consumes them, they fall through harmlessly as None. diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1c8cffd4c473..3434a39fa154 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -399,6 +399,7 @@ def _apply_profile_override() -> None: "-t", "--toolsets", "-r", "--resume", "-s", "--skills", + "--usage-file", } optional_value_flags = {"-c", "--continue"} i = 0 @@ -12240,6 +12241,7 @@ _TOP_LEVEL_VALUE_FLAGS = frozenset( "-t", "--toolsets", "-r", "--resume", "-s", "--skills", + "--usage-file", # ``-c / --continue`` is nargs='?' (optional value). Treat it as # value-taking: if the next token is a subcommand-looking word # the user almost certainly meant it as the session name, and @@ -12463,6 +12465,7 @@ def _try_termux_fast_cli_launch() -> bool: model=getattr(args, "model", None), provider=getattr(args, "provider", None), toolsets=getattr(args, "toolsets", None), + usage_file=getattr(args, "usage_file", None), ) ) @@ -14112,6 +14115,7 @@ def main(): model=getattr(args, "model", None), provider=getattr(args, "provider", None), toolsets=getattr(args, "toolsets", None), + usage_file=getattr(args, "usage_file", None), ) ) diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index 38d8e5e84bc9..a4c6ca9cc256 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -25,6 +25,7 @@ import logging import os import sys from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path from typing import Optional from hermes_cli.fallback_config import get_fallback_chain @@ -122,11 +123,49 @@ def _validate_explicit_toolsets(toolsets: object = None) -> tuple[list[str] | No return valid, None +def _write_usage_file(path: Optional[str], result: dict, failure: Optional[str] = None) -> None: + """Best-effort JSON usage report for pipelines (``-z --usage-file``). + + Written even on failure so callers can always account for spend. Never + raises — a broken usage write must not mask the run's own outcome. + """ + if not path: + return + try: + import json + + report = { + "estimated_cost_usd": result.get("estimated_cost_usd"), + "cost_status": result.get("cost_status"), + "cost_source": result.get("cost_source"), + "input_tokens": result.get("input_tokens"), + "output_tokens": result.get("output_tokens"), + "cache_read_tokens": result.get("cache_read_tokens"), + "cache_write_tokens": result.get("cache_write_tokens"), + "reasoning_tokens": result.get("reasoning_tokens"), + "total_tokens": result.get("total_tokens"), + "api_calls": result.get("api_calls"), + "model": result.get("model"), + "provider": result.get("provider"), + "session_id": result.get("session_id"), + "completed": result.get("completed"), + "failed": bool(result.get("failed")) or failure is not None, + } + if failure is not None: + report["failure"] = failure + out = Path(path).expanduser() + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + except Exception: + pass + + def run_oneshot( prompt: str, model: Optional[str] = None, provider: Optional[str] = None, toolsets: object = None, + usage_file: Optional[str] = None, ) -> int: """Execute a single prompt and print only the final content block. @@ -137,6 +176,10 @@ def run_oneshot( provider: Optional provider override. Falls back to config.yaml's model.provider, then "auto". toolsets: Optional comma-separated string or iterable of toolsets. + usage_file: Optional path; when set, a JSON usage report (estimated + cost, token counts, model, api_calls) is written there after the + run — even when the run fails — so pipelines can account for + spend per invocation. Returns the exit code. Caller should sys.exit() with the return. """ @@ -209,11 +252,15 @@ def run_oneshot( # Re-raise control-flow exceptions so the parent handles them as usual # (Ctrl-C / explicit sys.exit() inside the agent). if isinstance(failure, (KeyboardInterrupt, SystemExit)): + _write_usage_file(usage_file, result, failure=repr(failure)) raise failure + _write_usage_file(usage_file, result, failure=str(failure)) real_stderr.write(f"hermes -z: agent failed: {failure}\n") real_stderr.flush() return 1 + _write_usage_file(usage_file, result) + if response: real_stdout.write(response) if not response.endswith("\n"): diff --git a/tests/hermes_cli/test_oneshot_usage_file.py b/tests/hermes_cli/test_oneshot_usage_file.py new file mode 100644 index 000000000000..3bee96cbbdbe --- /dev/null +++ b/tests/hermes_cli/test_oneshot_usage_file.py @@ -0,0 +1,69 @@ +"""Tests for hermes -z --usage-file (per-run JSON usage report).""" + +import json + +from hermes_cli.oneshot import _write_usage_file + + +def _result(**overrides): + base = { + "estimated_cost_usd": 0.1234, + "cost_status": "estimated", + "cost_source": "pricing-table", + "input_tokens": 1000, + "output_tokens": 200, + "cache_read_tokens": 800, + "cache_write_tokens": 0, + "reasoning_tokens": 50, + "total_tokens": 1250, + "api_calls": 3, + "model": "openai/gpt-5.5", + "provider": "openrouter", + "session_id": "abc123", + "completed": True, + "failed": False, + } + base.update(overrides) + return base + + +class TestWriteUsageFile: + def test_writes_report_with_cost_and_tokens(self, tmp_path): + path = tmp_path / "usage.json" + _write_usage_file(str(path), _result()) + report = json.loads(path.read_text()) + assert report["estimated_cost_usd"] == 0.1234 + assert report["input_tokens"] == 1000 + assert report["output_tokens"] == 200 + assert report["model"] == "openai/gpt-5.5" + assert report["api_calls"] == 3 + assert report["failed"] is False + assert "failure" not in report + + def test_none_path_is_noop(self, tmp_path): + # Must not raise and must not create a report file. + _write_usage_file(None, _result()) + assert not (tmp_path / "usage.json").exists() + + def test_failure_marks_failed_and_records_message(self, tmp_path): + path = tmp_path / "usage.json" + _write_usage_file(str(path), {}, failure="boom") + report = json.loads(path.read_text()) + assert report["failed"] is True + assert report["failure"] == "boom" + # Missing result fields serialize as null, not KeyError. + assert report["estimated_cost_usd"] is None + + def test_creates_parent_directories(self, tmp_path): + path = tmp_path / "nested" / "dir" / "usage.json" + _write_usage_file(str(path), _result()) + assert json.loads(path.read_text())["total_tokens"] == 1250 + + def test_unwritable_path_never_raises(self): + # Root-owned path — the write must be swallowed, not raised. + _write_usage_file("/proc/definitely/not/writable/usage.json", _result()) + + def test_result_failed_flag_carries_through(self, tmp_path): + path = tmp_path / "usage.json" + _write_usage_file(str(path), _result(failed=True)) + assert json.loads(path.read_text())["failed"] is True diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index 2b6305ac8a30..34de95b826d2 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -379,6 +379,7 @@ def test_termux_fast_cli_launch_oneshot_uses_light_parser(monkeypatch, main_mod) "model": "gpt-test", "provider": "openai", "toolsets": None, + "usage_file": None, } @@ -617,6 +618,7 @@ def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod): "model": None, "provider": None, "toolsets": "web,terminal", + "usage_file": None, } From 077419b220e5c8b22a158c126f5fd1c5b709d5b7 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 6 Jun 2026 08:50:15 -0700 Subject: [PATCH 004/610] test(desktop): regression-guard fetchJsonViaOauthSession headers (#40069) Closes #40069. Salvaged from #40242; re-verified on main, tightened, tested. Co-authored-by: maxpetrusenkoagent --- .../electron/oauth-session-request.test.cjs | 30 +++++++++++++++++++ apps/desktop/package.json | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/electron/oauth-session-request.test.cjs diff --git a/apps/desktop/electron/oauth-session-request.test.cjs b/apps/desktop/electron/oauth-session-request.test.cjs new file mode 100644 index 000000000000..3254318456b6 --- /dev/null +++ b/apps/desktop/electron/oauth-session-request.test.cjs @@ -0,0 +1,30 @@ +/** + * Regression coverage for the OAuth-session Electron net.request path. + * + * Electron net rejects manual Content-Length/Host headers with + * net::ERR_INVALID_ARGUMENT. Node HTTP helpers may still set Content-Length; + * this guard is scoped to fetchJsonViaOauthSession only. + */ + +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const source = fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8') + +function extractFetchJsonViaOauthSession() { + const start = source.indexOf('function fetchJsonViaOauthSession') + const end = source.indexOf('// Mint a single-use WS ticket', start) + assert.notEqual(start, -1, 'fetchJsonViaOauthSession should exist') + assert.notEqual(end, -1, 'fetchJsonViaOauthSession boundary should exist') + return source.slice(start, end) +} + +test('OAuth Electron net request does not set forbidden Content-Length header', () => { + const fn = extractFetchJsonViaOauthSession() + + assert.match(fn, /electronNet\.request/) + assert.doesNotMatch(fn, /setHeader\(['"]Content-Length['"]/) + assert.match(fn, /request\.write\(body\)/) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6fd35c0fbc47..f5aac36ba423 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,7 +37,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/windows-hermes-resolution.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/windows-hermes-resolution.test.cjs electron/oauth-session-request.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", From 5431bf29214681fb2fd25254568b58c9da8ce6e0 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 6 Jun 2026 08:50:11 -0700 Subject: [PATCH 005/610] fix(desktop): default HERMES_DESKTOP_CWD to cwd when --cwd omitted Salvaged from #40363; re-verified on main, tightened, tested. Co-authored-by: alex-heritier --- hermes_cli/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3434a39fa154..60ceb4dd6104 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5598,6 +5598,8 @@ def cmd_gui(args: argparse.Namespace): env["HERMES_DESKTOP_HERMES_ROOT"] = str(Path(args.hermes_root).expanduser().resolve()) if getattr(args, "cwd", None): env["HERMES_DESKTOP_CWD"] = str(Path(args.cwd).expanduser().resolve()) + else: + env["HERMES_DESKTOP_CWD"] = os.getcwd() # Desktop launch options from config.yaml (`desktop.electron_flags`, # `desktop.disable_gpu`). The GPU policy is bridged to the env var the From 51e6ef5fca2220e7e0ba543701962ff06fc68740 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 6 Jun 2026 08:50:07 -0700 Subject: [PATCH 006/610] feat(banner): size skills display to terminal width instead of fixed 8/47 Salvaged from #40273; re-verified on main, tightened, tested. Co-authored-by: liuhao1024 --- hermes_cli/banner.py | 30 ++++++-- tests/hermes_cli/test_banner_skills_width.py | 77 ++++++++++++++++++++ 2 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 tests/hermes_cli/test_banner_skills_width.py diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 217eb2bb9656..01c64c7cdaf4 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -804,18 +804,34 @@ def build_welcome_banner(console: "Console", model: str, cwd: str, skills_by_category = {} total_skills = 0 + # Dynamically size skills display based on terminal width. + # Rich grid with 2 columns; right column gets roughly 60% of terminal. + _term_cols = shutil.get_terminal_size().columns + _right_col_width = max(int(_term_cols * 0.6) - 10, 30) + if not _skills_enabled: right_lines.append(f"[dim {dim}]Skills toolset disabled[/]") elif skills_by_category: for category in sorted(skills_by_category.keys()): skill_names = sorted(skills_by_category[category]) - if len(skill_names) > 8: - display_names = skill_names[:8] - skills_str = ", ".join(display_names) + f" +{len(skill_names) - 8} more" - else: - skills_str = ", ".join(skill_names) - if len(skills_str) > 50: - skills_str = skills_str[:47] + "..." + # Account for "category: " prefix + _prefix_len = len(category) + 2 + _avail = max(_right_col_width - _prefix_len, 20) + # Accumulate skills until we run out of space + parts, length = [], 0 + for i, name in enumerate(skill_names): + _sep = ", " if parts else "" + _needed = len(_sep) + len(name) + # Estimate indicator size IF we were to add this skill then stop + _after = len(skill_names) - (i + 1) # remaining after adding this + _ind_len = len(f", +{_after} more") if _after > 0 else 0 + if parts and length + _needed + _ind_len > _avail: + remaining = len(skill_names) - len(parts) + parts.append(f"+{remaining} more") + break + parts.append(name) + length += _needed + skills_str = ", ".join(parts) right_lines.append(f"[dim {dim}]{category}:[/] [{text}]{skills_str}[/]") else: right_lines.append(f"[dim {dim}]No skills installed[/]") diff --git a/tests/hermes_cli/test_banner_skills_width.py b/tests/hermes_cli/test_banner_skills_width.py new file mode 100644 index 000000000000..8a8db133e265 --- /dev/null +++ b/tests/hermes_cli/test_banner_skills_width.py @@ -0,0 +1,77 @@ +"""Tests for banner skills display — terminal-width-aware truncation.""" + +import os +from unittest.mock import patch + +from rich.console import Console + +import hermes_cli.banner as banner +import model_tools +import tools.mcp_tool + + +def _build_banner_with_skills(skills_by_category, term_width=160): + """Helper: build banner with given skills and return captured output.""" + with ( + patch.object( + model_tools, + "check_tool_availability", + return_value=([], []), + ), + patch.object(banner, "get_available_skills", return_value=skills_by_category), + patch.object(banner, "get_update_result", return_value=None), + patch.object(tools.mcp_tool, "get_mcp_status", return_value=[]), + patch("shutil.get_terminal_size", return_value=os.terminal_size((term_width, 50))), + ): + console = Console( + record=True, force_terminal=False, color_system=None, width=term_width + ) + banner.build_welcome_banner( + console=console, + model="anthropic/test-model", + cwd="/tmp/project", + tools=[], + ) + return console.export_text() + + +def test_wide_terminal_shows_more_than_8_skills(): + """A wide terminal should display more than 8 skills per category.""" + # 15 skills in one category + skills = {"research": [f"skill-{i:02d}" for i in range(15)]} + text = _build_banner_with_skills(skills, term_width=200) + + # With a 200-char terminal, more than 8 should be visible. + # The old code always truncated at 8; we should see at least 9 now. + assert "skill-08" in text, f"Expected skill-08 in output for wide terminal: {text}" + + +def test_narrow_terminal_limits_skills(): + """A narrow terminal should still limit skills to avoid wrapping.""" + skills = {"research": [f"skill-{i:02d}" for i in range(15)]} + text = _build_banner_with_skills(skills, term_width=80) + + # With an 80-char terminal, we should NOT see all 15 skills — some truncation + # is expected. Verify the "+N more" indicator is present. + assert "more" in text or "..." in text or "skill-00" in text + + +def test_small_category_shows_all_skills(): + """Categories with few skills should show all of them regardless of width.""" + skills = {"security": ["auth", "vault"]} + text = _build_banner_with_skills(skills, term_width=80) + + assert "auth" in text + assert "vault" in text + # No "+N more" indicator for small categories + assert "+2 more" not in text + + +def test_skills_respect_category_label_width(): + """Skills display should account for the category label prefix width.""" + # A category with a long name should have less room for skills + skills = {"very-long-category-name": [f"skill-{i:02d}" for i in range(10)]} + text = _build_banner_with_skills(skills, term_width=120) + + # Should still show at least some skills + assert "skill-00" in text From 94cdd56b8263b1f50b962907921ce970549555c7 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 6 Jun 2026 08:49:54 -0700 Subject: [PATCH 007/610] feat(plugins): surface entry-point plugins in hermes plugins list Salvaged from #40346; re-verified on main, tightened, tested. Co-authored-by: tjboudreaux --- hermes_cli/plugins_cmd.py | 45 +++++++++++++- tests/hermes_cli/test_plugins_cmd_list.py | 72 +++++++++++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 7d6284426ee3..6a7c39f3e4e0 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -10,6 +10,7 @@ rendered with Rich Markdown. Otherwise a default confirmation is shown. from __future__ import annotations import functools +import importlib.metadata import json import logging import os @@ -1015,11 +1016,11 @@ def _scan_level( def _discover_all_plugins() -> list: """Return a list of (name, version, description, source, dir_path, key) for - every plugin the loader can see — user + bundled + project. + every plugin the loader can see — user + bundled + project + entry point. Matches the ordering/dedup of ``PluginManager.discover_and_load``: - bundled first, then user, then project; user overrides bundled on - key collision. + bundled first, then user, then project, then entry points. Later sources + override earlier ones on key collision. """ seen: dict = {} # key -> (name, version, description, source, path, key) @@ -1031,9 +1032,47 @@ def _discover_all_plugins() -> list: (_plugins_dir(), "user", set()), ): _scan_level(base, source, skip, "", 0, seen) + + # Entry-point plugins (installed as Python packages; no plugin directory). + for name, version, description, path in _discover_entrypoint_plugins(): + seen[name] = (name, version, description, "entrypoint", path, name) return list(seen.values()) +def _discover_entrypoint_plugins() -> list[tuple[str, str, str, str]]: + """Return plugin entries advertised through ``hermes_agent.plugins``. + + Entry-point plugins are installed as Python packages, so they do not have a + plugin directory under ``~/.hermes/plugins``. Include package metadata here + so ``hermes plugins list`` can show and enable them. + """ + from hermes_cli.plugins import ENTRY_POINTS_GROUP + + try: + eps = importlib.metadata.entry_points() + if hasattr(eps, "select"): + group_eps = eps.select(group=ENTRY_POINTS_GROUP) + elif isinstance(eps, dict): + group_eps = eps.get(ENTRY_POINTS_GROUP, []) + else: + group_eps = [ep for ep in eps if ep.group == ENTRY_POINTS_GROUP] + except Exception as exc: + logger.debug("Entry-point plugin discovery failed: %s", exc) + return [] + + entries: list[tuple[str, str, str, str]] = [] + for ep in group_eps: + version = "" + description = "" + dist = getattr(ep, "dist", None) + metadata = getattr(dist, "metadata", None) + if metadata is not None: + version = str(getattr(dist, "version", "") or "") + description = str(metadata.get("Summary", "") or "") + entries.append((ep.name, version, description, ep.value)) + return entries + + def _plugin_status(name: str, enabled: set, disabled: set, key: str = "") -> str: """Return the user-facing activation state for a plugin name or key.""" if name in disabled or key in disabled: diff --git a/tests/hermes_cli/test_plugins_cmd_list.py b/tests/hermes_cli/test_plugins_cmd_list.py index 5e8c061dab45..98a7a5e73029 100644 --- a/tests/hermes_cli/test_plugins_cmd_list.py +++ b/tests/hermes_cli/test_plugins_cmd_list.py @@ -1,5 +1,6 @@ import argparse import json +from types import SimpleNamespace from hermes_cli import plugins_cmd @@ -86,3 +87,74 @@ def test_cmd_list_json_output(monkeypatch, capsys): "source": "git", } ] + + +def test_discover_all_plugins_includes_entrypoint_plugins(monkeypatch, tmp_path): + bundled_dir = tmp_path / "bundled" + user_dir = tmp_path / "user" + bundled_dir.mkdir() + user_dir.mkdir() + + dist = SimpleNamespace( + version="0.1.0", + metadata={"Summary": "Karpathy-style LLM Wikis for Hermes"}, + ) + entry_point = SimpleNamespace( + name="wiki", + value="adapters.hermes.cli_plugin", + group="hermes_agent.plugins", + dist=dist, + ) + + monkeypatch.setattr(plugins_cmd, "_plugins_dir", lambda: user_dir) + monkeypatch.setattr( + "hermes_cli.plugins.get_bundled_plugins_dir", + lambda: bundled_dir, + ) + monkeypatch.setattr( + plugins_cmd.importlib.metadata, + "entry_points", + lambda: [entry_point], + ) + + entries = plugins_cmd._discover_all_plugins() + + assert entries == [ + ( + "wiki", + "0.1.0", + "Karpathy-style LLM Wikis for Hermes", + "entrypoint", + "adapters.hermes.cli_plugin", + "wiki", + ) + ] + + +def test_cmd_list_json_output_includes_entrypoint_source(monkeypatch, capsys): + entries = [ + ( + "wiki", + "0.1.0", + "Karpathy-style LLM Wikis for Hermes", + "entrypoint", + "adapters.hermes.cli_plugin", + "wiki", + ) + ] + monkeypatch.setattr(plugins_cmd, "_discover_all_plugins", lambda: entries) + monkeypatch.setattr(plugins_cmd, "_get_enabled_set", lambda: {"wiki"}) + monkeypatch.setattr(plugins_cmd, "_get_disabled_set", lambda: set()) + + plugins_cmd.cmd_list(_args(json=True)) + + payload = json.loads(capsys.readouterr().out) + assert payload == [ + { + "name": "wiki", + "status": "enabled", + "version": "0.1.0", + "description": "Karpathy-style LLM Wikis for Hermes", + "source": "entrypoint", + } + ] From 1ea0bbbb0db0a9eae372ce14e6e4d74722cbee58 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 6 Jun 2026 08:49:52 -0700 Subject: [PATCH 008/610] feat(config): add display.timestamp_format and honor it in CLI timestamps Salvaged from #40303; re-verified on main, tightened, tested. Co-authored-by: pdmartins --- cli.py | 11 ++++++----- hermes_cli/config.py | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/cli.py b/cli.py index 7cc2302fb16d..e4e85720fb2b 100644 --- a/cli.py +++ b/cli.py @@ -3738,8 +3738,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # streaming: stream tokens to the terminal as they arrive (display.streaming in config.yaml) self.streaming_enabled = CLI_CONFIG["display"].get("streaming", False) - # show_timestamps: prefix user and assistant labels with [HH:MM] + # show_timestamps: prefix user and assistant labels with timestamps self.show_timestamps = CLI_CONFIG["display"].get("timestamps", False) + self.timestamp_format = CLI_CONFIG["display"].get("timestamp_format", "%H:%M") self.final_response_markdown = str( CLI_CONFIG["display"].get("final_response_markdown", "strip") ).strip().lower() or "strip" @@ -5482,7 +5483,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): def _format_submitted_user_message_preview(self, user_input: str) -> str: """Format the submitted user-message scrollback preview.""" ts_suffix = ( - f" [dim]{datetime.now().strftime('%H:%M')}[/]" + f" [dim]{datetime.now().strftime(getattr(self, 'timestamp_format', '%H:%M'))}[/]" if getattr(self, "show_timestamps", False) else "" ) lines = user_input.split("\n") @@ -5783,7 +5784,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): except (ValueError, IndexError): self._stream_text_ansi = "" if self.show_timestamps: - label = f"{label} {datetime.now().strftime('%H:%M')}" + label = f"{label} {datetime.now().strftime(getattr(self, 'timestamp_format', '%H:%M'))}" w = self._scrollback_box_width() fill = w - 2 - HermesCLI._status_bar_display_width(label) _cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}") @@ -6849,7 +6850,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): return "" try: from datetime import datetime - return f" [{datetime.fromtimestamp(float(ts)).strftime('%H:%M')}]" + return f" [{datetime.fromtimestamp(float(ts)).strftime(getattr(self, 'timestamp_format', '%H:%M'))}]" except (ValueError, OSError, TypeError): return "" @@ -12180,7 +12181,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): w = self._scrollback_box_width(getattr(self.console, "width", 80)) label = " ⚕ Hermes " if self.show_timestamps: - label = f"{label}{datetime.now().strftime('%H:%M')} " + label = f"{label}{datetime.now().strftime(getattr(self, 'timestamp_format', '%H:%M'))} " fill = w - 2 - HermesCLI._status_bar_display_width(label) _cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}") _cprint(f"{_STREAM_PAD}{sentence.rstrip()}") diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 9c897887801c..3801ebd4d94e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1717,7 +1717,8 @@ DEFAULT_CONFIG = { # Per-platform overrides via display.platforms..memory_notifications. "memory_notifications": "on", "streaming": False, - "timestamps": False, # Show [HH:MM] on user and assistant labels + "timestamps": False, # Show timestamp on user and assistant labels + "timestamp_format": "%H:%M", # strftime format for timestamps (e.g. "%b-%d %H:%M") "final_response_markdown": "strip", # render | strip | raw # Preserve recent classic CLI output across Ctrl+L, /redraw, and # terminal resize full-screen clears. Disable if a terminal emulator From b24ff550c34e775a20f2ef05384b69cfdfc71668 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:11:31 -0700 Subject: [PATCH 009/610] docs: Plugins subcategory under Extending + secret-source plugin guide + 1Password sidebar fix (#59613) * docs(secrets): secret-source plugin developer guide + sidebar registration for 1Password page - New developer-guide/secret-source-plugin.md: SecretSource contract (never raises/prompts, fetch-only, timeout budget), framework-vs-plugin ownership table, mapped-vs-bulk shape guidance, run_secret_cli() subprocess-safety, registration + timing note, conformance kit usage, ErrorKind reference. - Register user-guide/secrets/onepassword in the sidebar (page shipped in #59498 but was not listed, so it was unreachable from nav). - Cross-link the user-guide plugin section to the new dev guide. * docs: group all plugin guides under a Plugins subcategory in Extending - Move guides/build-a-hermes-plugin.md -> developer-guide/plugins/index.md (both locales) and make it the category landing page (slug pinned to /developer-guide/plugins). - New sidebar subcategory Developer Guide > Extending > Plugins holding the general guide + all 8 provider-plugin docs (llm-access, memory, context-engine, secret-source, model, image-gen, video-gen, web-search); provider-doc URLs unchanged. - Client redirect /guides/build-a-hermes-plugin -> /developer-guide/plugins. - Update 30 cross-links across both locales. --- website/docs/developer-guide/adding-tools.md | 2 +- website/docs/developer-guide/architecture.md | 2 +- website/docs/developer-guide/contributing.md | 2 +- .../image-gen-provider-plugin.md | 6 +- .../developer-guide/model-provider-plugin.md | 4 +- .../plugins/index.md} | 2 +- .../developer-guide/secret-source-plugin.md | 171 ++++++++++++++++++ .../web-search-provider-plugin.md | 10 +- website/docs/getting-started/learning-path.md | 2 +- website/docs/guides/github-pr-review-agent.md | 2 +- .../docs/guides/webhook-github-pr-review.md | 2 +- website/docs/guides/work-with-skills.md | 2 +- website/docs/integrations/index.md | 2 +- website/docs/reference/cli-commands.md | 2 +- .../user-guide/features/built-in-plugins.md | 4 +- website/docs/user-guide/features/hooks.md | 2 +- website/docs/user-guide/features/plugins.md | 10 +- website/docs/user-guide/secrets/index.md | 2 +- website/docusaurus.config.ts | 6 + .../current/developer-guide/adding-tools.md | 2 +- .../current/developer-guide/architecture.md | 2 +- .../current/developer-guide/contributing.md | 2 +- .../image-gen-provider-plugin.md | 6 +- .../developer-guide/model-provider-plugin.md | 4 +- .../plugins/index.md} | 1 - .../web-search-provider-plugin.md | 10 +- .../current/getting-started/learning-path.md | 2 +- .../current/guides/github-pr-review-agent.md | 2 +- .../guides/webhook-github-pr-review.md | 2 +- .../current/guides/work-with-skills.md | 2 +- .../current/integrations/index.md | 2 +- .../current/reference/cli-commands.md | 2 +- .../user-guide/features/built-in-plugins.md | 4 +- .../current/user-guide/features/hooks.md | 2 +- .../current/user-guide/features/plugins.md | 10 +- website/sidebars.ts | 24 ++- 36 files changed, 249 insertions(+), 65 deletions(-) rename website/docs/{guides/build-a-hermes-plugin.md => developer-guide/plugins/index.md} (99%) create mode 100644 website/docs/developer-guide/secret-source-plugin.md rename website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/{guides/build-a-hermes-plugin.md => developer-guide/plugins/index.md} (99%) diff --git a/website/docs/developer-guide/adding-tools.md b/website/docs/developer-guide/adding-tools.md index 0fe6d795ae47..975bdaca0603 100644 --- a/website/docs/developer-guide/adding-tools.md +++ b/website/docs/developer-guide/adding-tools.md @@ -14,7 +14,7 @@ If you want a personal, project-local, or otherwise custom tool without modifying Hermes core, use the plugin route instead: - [Plugins](/user-guide/features/plugins) -- [Build a Hermes Plugin](/guides/build-a-hermes-plugin) +- [Build a Hermes Plugin](/developer-guide/plugins) Default to plugins for most custom tool creation. Only follow this page when you explicitly want to ship a new built-in tool in `tools/` and `toolsets.py`. diff --git a/website/docs/developer-guide/architecture.md b/website/docs/developer-guide/architecture.md index f3698f8e52a3..6d6ec8695ae4 100644 --- a/website/docs/developer-guide/architecture.md +++ b/website/docs/developer-guide/architecture.md @@ -231,7 +231,7 @@ Long-running process with 20 platform adapters, unified session routing, user au Three discovery sources: `~/.hermes/plugins/` (user), `.hermes/plugins/` (project), and pip entry points. Plugins register tools, hooks, and CLI commands through a context API. Two specialized plugin types exist: memory providers (`plugins/memory/`) and context engines (`plugins/context_engine/`). Both are single-select — only one of each can be active at a time, configured via `hermes plugins` or `config.yaml`. -→ [Plugin Guide](/guides/build-a-hermes-plugin), [Memory Provider Plugin](./memory-provider-plugin.md) +→ [Plugin Guide](/developer-guide/plugins), [Memory Provider Plugin](./memory-provider-plugin.md) ### Cron diff --git a/website/docs/developer-guide/contributing.md b/website/docs/developer-guide/contributing.md index e9cc9629cdac..52ec92d28ad4 100644 --- a/website/docs/developer-guide/contributing.md +++ b/website/docs/developer-guide/contributing.md @@ -22,7 +22,7 @@ We value contributions in this order: ## Common contribution paths -- Building a custom/local tool without modifying Hermes core? Start with [Build a Hermes Plugin](../guides/build-a-hermes-plugin.md) +- Building a custom/local tool without modifying Hermes core? Start with [Build a Hermes Plugin](../developer-guide/plugins/index.md) - Building a new built-in core tool for Hermes itself? Start with [Adding Tools](./adding-tools.md) - Building a new skill? Start with [Creating Skills](./creating-skills.md) - Building a new inference provider? Start with [Adding Providers](./adding-providers.md) diff --git a/website/docs/developer-guide/image-gen-provider-plugin.md b/website/docs/developer-guide/image-gen-provider-plugin.md index b746ce82229c..66179cfa5c0b 100644 --- a/website/docs/developer-guide/image-gen-provider-plugin.md +++ b/website/docs/developer-guide/image-gen-provider-plugin.md @@ -9,7 +9,7 @@ description: "How to build an image-generation backend plugin for Hermes Agent" Image-gen provider plugins register a backend that services every `image_generate` tool call — DALL·E, gpt-image, Grok, Flux, Imagen, Stable Diffusion, fal, Replicate, a local ComfyUI rig, anything. Built-in providers (OpenAI, OpenAI-Codex, xAI) all ship as plugins. You can add a new one, or override a bundled one, by dropping a directory into `plugins/image_gen//`. :::tip -Image-gen is one of several **backend plugins** Hermes supports. The others (with more specialized ABCs) are [Memory Provider Plugins](/developer-guide/memory-provider-plugin), [Context Engine Plugins](/developer-guide/context-engine-plugin), and [Model Provider Plugins](/developer-guide/model-provider-plugin). General tool/hook/CLI plugins live in [Build a Hermes Plugin](/guides/build-a-hermes-plugin). +Image-gen is one of several **backend plugins** Hermes supports. The others (with more specialized ABCs) are [Memory Provider Plugins](/developer-guide/memory-provider-plugin), [Context Engine Plugins](/developer-guide/context-engine-plugin), and [Model Provider Plugins](/developer-guide/model-provider-plugin). General tool/hook/CLI plugins live in [Build a Hermes Plugin](/developer-guide/plugins). ::: ## How discovery works @@ -307,10 +307,10 @@ Or interactively: `hermes tools` → "Image Generation" → select `my-backend` my-backend-imggen = "my_backend_imggen_package" ``` -`my_backend_imggen_package` must expose a top-level `register` function. See [Distribute via pip](/guides/build-a-hermes-plugin#distribute-via-pip) in the general plugin guide for the full setup. +`my_backend_imggen_package` must expose a top-level `register` function. See [Distribute via pip](/developer-guide/plugins#distribute-via-pip) in the general plugin guide for the full setup. ## Related pages - [Image Generation](/user-guide/features/image-generation) — user-facing feature documentation - [Plugins overview](/user-guide/features/plugins) — all plugin types at a glance -- [Build a Hermes Plugin](/guides/build-a-hermes-plugin) — general tools/hooks/slash commands guide +- [Build a Hermes Plugin](/developer-guide/plugins) — general tools/hooks/slash commands guide diff --git a/website/docs/developer-guide/model-provider-plugin.md b/website/docs/developer-guide/model-provider-plugin.md index f12ed3abf336..6c20a66be425 100644 --- a/website/docs/developer-guide/model-provider-plugin.md +++ b/website/docs/developer-guide/model-provider-plugin.md @@ -257,7 +257,7 @@ acme-inference = "acme_hermes_plugin:register" …where `acme_hermes_plugin:register` is a function that calls `register_provider(profile)`. The general PluginManager picks up entry-point plugins during `discover_and_load()`. For `kind: model-provider` pip plugins, you still need to declare the kind in your manifest (or rely on the source-text heuristic). -See [Building a Hermes Plugin](/guides/build-a-hermes-plugin#distribute-via-pip) for the full entry-points setup. +See [Building a Hermes Plugin](/developer-guide/plugins#distribute-via-pip) for the full entry-points setup. ## Related pages @@ -265,4 +265,4 @@ See [Building a Hermes Plugin](/guides/build-a-hermes-plugin#distribute-via-pip) - [Adding Providers](/developer-guide/adding-providers) — end-to-end checklist for new inference backends (covers both the fast plugin path and the full CLI/auth integration) - [Memory Provider Plugins](/developer-guide/memory-provider-plugin) - [Context Engine Plugins](/developer-guide/context-engine-plugin) -- [Building a Hermes Plugin](/guides/build-a-hermes-plugin) — general plugin authoring +- [Building a Hermes Plugin](/developer-guide/plugins) — general plugin authoring diff --git a/website/docs/guides/build-a-hermes-plugin.md b/website/docs/developer-guide/plugins/index.md similarity index 99% rename from website/docs/guides/build-a-hermes-plugin.md rename to website/docs/developer-guide/plugins/index.md index 8c8f139b4ff6..1fa5d1c5e20a 100644 --- a/website/docs/guides/build-a-hermes-plugin.md +++ b/website/docs/developer-guide/plugins/index.md @@ -1,6 +1,6 @@ --- -sidebar_position: 9 sidebar_label: "Build a Plugin" +slug: /developer-guide/plugins title: "Build a Hermes Plugin" description: "Step-by-step guide to building a complete Hermes plugin with tools, hooks, data files, and skills" --- diff --git a/website/docs/developer-guide/secret-source-plugin.md b/website/docs/developer-guide/secret-source-plugin.md new file mode 100644 index 000000000000..aeecc96053e6 --- /dev/null +++ b/website/docs/developer-guide/secret-source-plugin.md @@ -0,0 +1,171 @@ +--- +sidebar_position: 9 +title: "Secret Source Plugins" +description: "How to build a secret-manager backend plugin for Hermes Agent" +--- + +# Building a Secret Source Plugin + +Secret sources resolve provider credentials from an external secret manager (a vault, a password manager, an OS keystore, a custom script) into environment variables at process startup — after `~/.hermes/.env` loads, before Hermes reads credentials. Bitwarden and 1Password ship in-tree; **every other backend is a plugin**. This guide covers building one. + +:::tip +The bundled set is deliberately closed, same policy as [memory providers](/developer-guide/memory-provider-plugin): PRs adding new vault backends under `agent/secret_sources/` are closed with a pointer to this guide. Publish your backend as a standalone plugin repo and share it in the Nous Research Discord (`#plugins-skills-and-skins`). +::: + +## What the framework owns vs. what you own + +The orchestrator (`agent.secret_sources.registry.apply_all`) owns everything security- and precedence-sensitive, so a backend cannot get it wrong: + +| Framework owns | You own | +|---|---| +| Source ordering, mapped-vs-bulk precedence | Fetching values from your backend | +| First-claim-wins conflict handling + warnings | Validating your reference format | +| `override_existing` semantics (never crosses sources) | Talking to your CLI/SDK/API | +| Protected bootstrap tokens | Declaring which env var IS your bootstrap token | +| Per-source wall-clock timeout | Keeping `fetch()` reasonably fast | +| Per-var provenance + `(from X)` labels | A human-readable `label` | +| `os.environ` writes | Nothing — you never touch the environment | + +## Directory structure + +``` +~/.hermes/plugins/my-vault/ +├── plugin.yaml # name, description +└── __init__.py # SecretSource subclass + register(ctx) +``` + +## The SecretSource ABC + +Implement `agent.secret_sources.base.SecretSource`. One method is required: + +```python +from pathlib import Path + +from agent.secret_sources.base import ( + ErrorKind, + FetchResult, + SecretSource, + run_secret_cli, +) + + +class MyVaultSource(SecretSource): + name = "myvault" # config section key: secrets.myvault + label = "My Vault" # used in startup lines + provenance labels + shape = "mapped" # "mapped" (explicit VAR→ref map) or "bulk" (project dump) + scheme = "mv" # optional: unique URI scheme you own (mv://...) + + def fetch(self, cfg: dict, home_path: Path) -> FetchResult: + """Resolve secrets. MUST NOT raise. MUST NOT prompt.""" + result = FetchResult() + token = os.environ.get("MYVAULT_TOKEN", "").strip() + if not token: + result.error = "secrets.myvault.enabled is true but MYVAULT_TOKEN is not set." + result.error_kind = ErrorKind.NOT_CONFIGURED + return result + + try: + proc = run_secret_cli( + ["myvault-cli", "export", "--json"], + allow_env=["MYVAULT_TOKEN"], # ONLY your auth vars — never full os.environ + timeout=30, + ) + except RuntimeError as exc: # spawn failure / timeout + result.error = str(exc) + result.error_kind = ErrorKind.BINARY_MISSING + return result + + if proc.returncode != 0: + result.error = f"myvault-cli exited {proc.returncode}: {proc.stderr[:200]}" + result.error_kind = ErrorKind.AUTH_FAILED + return result + + result.secrets = parse_your_output(proc.stdout) # {ENV_VAR: value} + return result + + def protected_env_vars(self, cfg: dict): + # Your bootstrap token — no source (including yours) may ever overwrite it. + return frozenset({"MYVAULT_TOKEN"}) +``` + +### Contract rules (enforced, not suggestions) + +- **`fetch()` never raises.** Errors go in `result.error` + `result.error_kind`. A raising fetch is contained by the orchestrator and reported as `INTERNAL` — a contract violation, not a feature. +- **`fetch()` never prompts.** Startup runs in non-TTY contexts (gateway, cron, Docker). `run_secret_cli()` closes stdin so a prompting helper fails fast. Interactive auth belongs in your CLI setup flow, never on the startup path. +- **Sync, within budget.** The orchestrator enforces a wall-clock timeout (default 120s, user-tunable via `secrets..timeout_seconds`). Exceeding it reports `TIMEOUT` and your result is discarded. +- **You fetch; the orchestrator applies.** Return the mapping you *would* contribute. Never write `os.environ` yourself — you'd bypass precedence, conflict detection, and provenance. +- **API versioning.** `SecretSource.api_version` defaults to the current `SECRET_SOURCE_API_VERSION`. The registry skips (with a warning) sources built against a different version instead of crashing startup. + +### Choosing your `shape` + +- `mapped` — the user explicitly binds env-var names to references in config (like 1Password's `env:` map). Strongest intent: mapped claims beat bulk claims on contested vars. +- `bulk` — you inject a whole project/folder of secrets implicitly (like Bitwarden BSM). Yields to mapped sources. + +### Optional hooks + +| Method | Default | Override when | +|---|---|---| +| `is_enabled(cfg)` | `cfg.get("enabled")` | Custom activation logic | +| `override_existing(cfg)` | `cfg.get("override_existing", False)` | You want a different default (both bundled sources default `True` for rotation) | +| `protected_env_vars(cfg)` | empty | You have a bootstrap token (you almost certainly do) | +| `fetch_timeout_seconds(cfg)` | 120s | Your backend needs a different budget | +| `config_schema()` | `{}` | Declare config keys for setup surfaces | + +## Subprocess safety: use `run_secret_cli()` + +If your backend shells out to a CLI, use the shared helper instead of `subprocess.run` directly. It gives you the audited posture for free: argv-only (no `shell=True`), a **minimal allowlisted child environment** (by the time sources run, `os.environ` holds every credential Hermes knows — never hand that to a child process), `NO_COLOR` + ANSI-scrubbed stderr, stdin closed, timeout → clean `RuntimeError`. Pass user-supplied reference strings after a `--` terminator in your argv so they can never parse as flags. + +## Registering + +```python +# __init__.py +def register(ctx): + ctx.register_secret_source(MyVaultSource()) +``` + +Registration is rejected (with a log warning, never a crash) for: non-`SecretSource` instances, invalid/duplicate names, a `scheme` another source owns, wrong `api_version`, or a `shape` outside `mapped`/`bulk`. + +:::note Timing +Plugin discovery runs later in startup than the first `load_hermes_dotenv()` call, so a plugin source is not consulted by the very first env load of the process that discovers it. It IS consulted by every subsequently spawned Hermes process (gateway children, cron sessions, subagents). Bundled sources cover first-process bootstrap. +::: + +## Users configure it like any other source + +```yaml +secrets: + sources: [myvault, bitwarden] # optional ordering + myvault: + enabled: true + # ... your config_schema keys +``` + +Multi-source precedence, conflict warnings, and `(from My Vault)` provenance labels all work automatically — see the [user-facing secrets docs](/user-guide/secrets/) for the precedence ladder. + +## Validate with the conformance kit + +Subclass the kit from the Hermes repo (`tests/secret_sources/conformance.py`) in your plugin's tests: + +```python +import pytest +from tests.secret_sources.conformance import SecretSourceConformance + +class TestMyVaultConformance(SecretSourceConformance): + @pytest.fixture + def source(self): + return MyVaultSource() +``` + +It checks the rules that break other people when violated: never-raises on malformed config, machine-readable error kinds, disabled-by-default, positive timeouts, valid protected-var names, and a full `apply_all()` round trip. Green conformance is the review bar for calling a backend contract-compliant. + +## ErrorKind reference + +| Kind | Meaning | +|---|---| +| `NOT_CONFIGURED` | Enabled but missing token / project / map | +| `BINARY_MISSING` | Helper CLI not found or not executable | +| `AUTH_FAILED` / `AUTH_EXPIRED` | Bad / expired credentials | +| `REF_INVALID` | A secret reference failed validation | +| `NETWORK` | Transport-level failure | +| `EMPTY_VALUE` | Backend returned nothing for a ref — never apply `""` over a good credential | +| `TIMEOUT` | Fetch exceeded its budget | +| `INTERNAL` | Anything else (bug, unexpected shape) | diff --git a/website/docs/developer-guide/web-search-provider-plugin.md b/website/docs/developer-guide/web-search-provider-plugin.md index 880cad8886e4..4f880866c699 100644 --- a/website/docs/developer-guide/web-search-provider-plugin.md +++ b/website/docs/developer-guide/web-search-provider-plugin.md @@ -9,7 +9,7 @@ description: "How to build a web-search/extract/crawl backend plugin for Hermes Web-search provider plugins register a backend that services `web_search`, `web_extract`, and (optionally) deep-crawl tool calls. Built-in providers — Firecrawl, SearXNG, Tavily, Exa, Parallel, Brave Search (free tier), xAI, and DDGS — all ship as plugins under `plugins/web//`. You can add a new one, or override a bundled one, by dropping a directory next to them. :::tip -Web search is one of several **backend plugins** Hermes supports. The others (with their own ABCs) are [Image Generation Provider Plugins](/developer-guide/image-gen-provider-plugin), [Video Generation Provider Plugins](/developer-guide/video-gen-provider-plugin), [Memory Provider Plugins](/developer-guide/memory-provider-plugin), [Context Engine Plugins](/developer-guide/context-engine-plugin), and [Model Provider Plugins](/developer-guide/model-provider-plugin). General tool/hook/CLI plugins live in [Build a Hermes Plugin](/guides/build-a-hermes-plugin). +Web search is one of several **backend plugins** Hermes supports. The others (with their own ABCs) are [Image Generation Provider Plugins](/developer-guide/image-gen-provider-plugin), [Video Generation Provider Plugins](/developer-guide/video-gen-provider-plugin), [Memory Provider Plugins](/developer-guide/memory-provider-plugin), [Context Engine Plugins](/developer-guide/context-engine-plugin), and [Model Provider Plugins](/developer-guide/model-provider-plugin). General tool/hook/CLI plugins live in [Build a Hermes Plugin](/developer-guide/plugins). ::: ## How discovery works @@ -141,7 +141,7 @@ requires_env: |---|---| | `kind: backend` | Routes the plugin through the backend-loading path | | `provides_web_providers` | List of provider `name`s this plugin registers — used by the loader to advertise the plugin in `hermes tools` even before `register()` runs | -| `requires_env` | Interactive credential prompt during `hermes plugins install` (see [Build a Hermes Plugin](/guides/build-a-hermes-plugin#gate-on-environment-variables) for the rich format) | +| `requires_env` | Interactive credential prompt during `hermes plugins install` (see [Build a Hermes Plugin](/developer-guide/plugins#gate-on-environment-variables) for the rich format) | ## ABC reference @@ -233,7 +233,7 @@ Errors surface as the tool result; the LLM decides how to explain them. If no pr ## Lazy-installing optional dependencies -If your provider wraps a third-party SDK (like DDGS does with the `ddgs` package), don't `import` it at module top level. Use `tools.lazy_deps.ensure(...)` inside `is_available()` or `search()` — Hermes will install the package on first use, gated by `security.allow_lazy_installs`. See [Build a Hermes Plugin → Lazy-install](/guides/build-a-hermes-plugin#lazy-install-optional-python-dependencies) for the security model. +If your provider wraps a third-party SDK (like DDGS does with the `ddgs` package), don't `import` it at module top level. Use `tools.lazy_deps.ensure(...)` inside `is_available()` or `search()` — Hermes will install the package on first use, gated by `security.allow_lazy_installs`. See [Build a Hermes Plugin → Lazy-install](/developer-guide/plugins#lazy-install-optional-python-dependencies) for the security model. ## Reference implementations @@ -251,10 +251,10 @@ If your provider wraps a third-party SDK (like DDGS does with the `ddgs` package my-backend-web = "my_backend_web_package" ``` -`my_backend_web_package` must expose a top-level `register` function. See [Distribute via pip](/guides/build-a-hermes-plugin#distribute-via-pip) in the general plugin guide for the full setup. +`my_backend_web_package` must expose a top-level `register` function. See [Distribute via pip](/developer-guide/plugins#distribute-via-pip) in the general plugin guide for the full setup. ## Related pages - [Web Search](/user-guide/features/web-search) — user-facing feature documentation and per-backend configuration - [Plugins overview](/user-guide/features/plugins) — all plugin types at a glance -- [Build a Hermes Plugin](/guides/build-a-hermes-plugin) — general tools/hooks/slash commands guide +- [Build a Hermes Plugin](/developer-guide/plugins) — general tools/hooks/slash commands guide diff --git a/website/docs/getting-started/learning-path.md b/website/docs/getting-started/learning-path.md index 619e2010394a..a643267637a0 100644 --- a/website/docs/getting-started/learning-path.md +++ b/website/docs/getting-started/learning-path.md @@ -85,7 +85,7 @@ Cron jobs let Hermes Agent run tasks on a schedule — daily summaries, periodic Extend Hermes Agent with your own tools and reusable skill packages. 1. [Plugins](/user-guide/features/plugins) -2. [Build a Hermes Plugin](/guides/build-a-hermes-plugin) +2. [Build a Hermes Plugin](/developer-guide/plugins) 3. [Tools Overview](/user-guide/features/tools) 4. [Skills Overview](/user-guide/features/skills) 5. [MCP (Model Context Protocol)](/user-guide/features/mcp) diff --git a/website/docs/guides/github-pr-review-agent.md b/website/docs/guides/github-pr-review-agent.md index b5fe0a525b28..f45684973bd4 100644 --- a/website/docs/guides/github-pr-review-agent.md +++ b/website/docs/guides/github-pr-review-agent.md @@ -298,6 +298,6 @@ GitHub allows 5,000 API requests/hour for authenticated users. Each PR review us - **[Webhook-Based PR Reviews](./webhook-github-pr-review.md)** — get instant reviews when PRs are opened (requires a public endpoint) - **[Daily Briefing Bot](/guides/daily-briefing-bot)** — combine PR reviews with your morning news digest -- **[Build a Plugin](/guides/build-a-hermes-plugin)** — wrap the review logic into a shareable plugin +- **[Build a Plugin](/developer-guide/plugins)** — wrap the review logic into a shareable plugin - **[Profiles](/user-guide/profiles)** — run a dedicated reviewer profile with its own memory and config - **[Fallback Providers](/user-guide/features/fallback-providers)** — ensure reviews run even when one provider is down diff --git a/website/docs/guides/webhook-github-pr-review.md b/website/docs/guides/webhook-github-pr-review.md index f3f3666e2c41..8c0551a5af17 100644 --- a/website/docs/guides/webhook-github-pr-review.md +++ b/website/docs/guides/webhook-github-pr-review.md @@ -325,5 +325,5 @@ platforms: - **[Cron-Based PR Reviews](./github-pr-review-agent.md)** — poll for PRs on a schedule, no public endpoint needed - **[Webhook Reference](/user-guide/messaging/webhooks)** — full config reference for the webhook platform -- **[Build a Plugin](/guides/build-a-hermes-plugin)** — package review logic into a shareable plugin +- **[Build a Plugin](/developer-guide/plugins)** — package review logic into a shareable plugin - **[Profiles](/user-guide/profiles)** — run a dedicated reviewer profile with its own memory and config diff --git a/website/docs/guides/work-with-skills.md b/website/docs/guides/work-with-skills.md index 331558924e02..f191ff146d11 100644 --- a/website/docs/guides/work-with-skills.md +++ b/website/docs/guides/work-with-skills.md @@ -135,7 +135,7 @@ skill_view("writing-plans") Plugin skills are **not** listed in the system prompt and don't appear in `skills_list`. They're opt-in — load them explicitly when you know a plugin provides one. When loaded, the agent sees a banner listing sibling skills from the same plugin. -For how to ship skills in your own plugin, see [Build a Hermes Plugin → Bundle skills](/guides/build-a-hermes-plugin#bundle-skills). +For how to ship skills in your own plugin, see [Build a Hermes Plugin → Bundle skills](/developer-guide/plugins#bundle-skills). --- diff --git a/website/docs/integrations/index.md b/website/docs/integrations/index.md index e3389b33abda..90bd0a4042a6 100644 --- a/website/docs/integrations/index.md +++ b/website/docs/integrations/index.md @@ -102,7 +102,7 @@ See the [Messaging Gateway overview](/user-guide/messaging) for the platform com ## Plugins - **[Plugin System](/user-guide/features/plugins)** — Extend Hermes with custom tools, lifecycle hooks, and CLI commands without modifying core code. Plugins are discovered from `~/.hermes/plugins/`, project-local `.hermes/plugins/`, and pip-installed entry points. -- **[Build a Plugin](/guides/build-a-hermes-plugin)** — Step-by-step guide for creating Hermes plugins with tools, hooks, and CLI commands. +- **[Build a Plugin](/developer-guide/plugins)** — Step-by-step guide for creating Hermes plugins with tools, hooks, and CLI commands. ## Training & Evaluation diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 69e9d702253c..1bc4d4b131b8 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1289,7 +1289,7 @@ Provider plugin selections are saved to `config.yaml`: General plugin disabled list is stored in `config.yaml` under `plugins.disabled`. -See [Plugins](../user-guide/features/plugins.md) and [Build a Hermes Plugin](../guides/build-a-hermes-plugin.md). +See [Plugins](../user-guide/features/plugins.md) and [Build a Hermes Plugin](../developer-guide/plugins/index.md). ## `hermes tools` diff --git a/website/docs/user-guide/features/built-in-plugins.md b/website/docs/user-guide/features/built-in-plugins.md index b4c1d5ef0804..722105fcf67a 100644 --- a/website/docs/user-guide/features/built-in-plugins.md +++ b/website/docs/user-guide/features/built-in-plugins.md @@ -9,7 +9,7 @@ description: "Plugins shipped with Hermes Agent that run automatically via lifec Hermes ships a small set of plugins bundled with the repository. They live under `/plugins//` and load automatically alongside user-installed plugins in `~/.hermes/plugins/`. They use the same plugin surface as third-party plugins — hooks, tools, slash commands — just maintained in-tree. -See the [Plugins](/user-guide/features/plugins) page for the general plugin system, and [Build a Hermes Plugin](/guides/build-a-hermes-plugin) to write your own. +See the [Plugins](/user-guide/features/plugins) page for the general plugin system, and [Build a Hermes Plugin](/developer-guide/plugins) to write your own. ## How discovery works @@ -286,7 +286,7 @@ Adds a **Steam-style achievements tab to the dashboard** — 60+ collectible, ti ## Adding a bundled plugin -Bundled plugins are written exactly like any other Hermes plugin — see [Build a Hermes Plugin](/guides/build-a-hermes-plugin). The only differences are: +Bundled plugins are written exactly like any other Hermes plugin — see [Build a Hermes Plugin](/developer-guide/plugins). The only differences are: - Directory lives at `/plugins//` instead of `~/.hermes/plugins//` - Manifest source is reported as `bundled` in `hermes plugins list` diff --git a/website/docs/user-guide/features/hooks.md b/website/docs/user-guide/features/hooks.md index 4a2fc0686061..224d20198b30 100644 --- a/website/docs/user-guide/features/hooks.md +++ b/website/docs/user-guide/features/hooks.md @@ -873,7 +873,7 @@ def my_callback(session_id: str, platform: str, **kwargs): --- -See the **[Build a Plugin guide](/guides/build-a-hermes-plugin)** for the full walkthrough including tool schemas, handlers, and advanced hook patterns. +See the **[Build a Plugin guide](/developer-guide/plugins)** for the full walkthrough including tool schemas, handlers, and advanced hook patterns. --- diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index f543b5610a2c..ed8012325b6e 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -14,7 +14,7 @@ this is usually the right path. The developer guide's [Adding Tools](/developer-guide/adding-tools) page is for built-in Hermes core tools that live in `tools/` and `toolsets.py`. -**→ [Build a Hermes Plugin](/guides/build-a-hermes-plugin)** — step-by-step guide with a complete working example. +**→ [Build a Hermes Plugin](/developer-guide/plugins)** — step-by-step guide with a complete working example. ## Quick overview @@ -221,9 +221,9 @@ The table above shows the four plugin categories, but within "General plugins" t | Want to add… | How | Authoring guide | |---|---|---| -| A **tool** the LLM can call | Python plugin — `ctx.register_tool()` | [Build a Hermes Plugin](/guides/build-a-hermes-plugin) · [Adding Tools](/developer-guide/adding-tools) | -| A **lifecycle hook** (pre/post LLM, session start/end, tool filter) | Python plugin — `ctx.register_hook()` | [Hooks reference](/user-guide/features/hooks) · [Build a Hermes Plugin](/guides/build-a-hermes-plugin) | -| A **slash command** for the CLI / gateway | Python plugin — `ctx.register_command()` | [Build a Hermes Plugin](/guides/build-a-hermes-plugin) · [Extending the CLI](/developer-guide/extending-the-cli) | +| A **tool** the LLM can call | Python plugin — `ctx.register_tool()` | [Build a Hermes Plugin](/developer-guide/plugins) · [Adding Tools](/developer-guide/adding-tools) | +| A **lifecycle hook** (pre/post LLM, session start/end, tool filter) | Python plugin — `ctx.register_hook()` | [Hooks reference](/user-guide/features/hooks) · [Build a Hermes Plugin](/developer-guide/plugins) | +| A **slash command** for the CLI / gateway | Python plugin — `ctx.register_command()` | [Build a Hermes Plugin](/developer-guide/plugins) · [Extending the CLI](/developer-guide/extending-the-cli) | | A **subcommand** for `hermes ` | Python plugin — `ctx.register_cli_command()` | [Extending the CLI](/developer-guide/extending-the-cli) | | A bundled **skill** that your plugin ships | Python plugin — `ctx.register_skill()` | [Creating Skills](/developer-guide/creating-skills) | | An **inference backend** (LLM provider: OpenAI-compat, Codex, Anthropic-Messages, Bedrock) | Provider plugin — `register_provider(ProviderProfile(...))` in `plugins/model-providers//` | **[Model Provider Plugins](/developer-guide/model-provider-plugin)** · [Adding Providers](/developer-guide/adding-providers) | @@ -343,4 +343,4 @@ This enables plugins like remote control viewers, messaging bridges, or webhook `inject_message` is only available in CLI mode. In gateway mode, there is no CLI reference and the method returns `False`. ::: -See the **[full guide](/guides/build-a-hermes-plugin)** for handler contracts, schema format, hook behavior, error handling, and common mistakes. +See the **[full guide](/developer-guide/plugins)** for handler contracts, schema format, hook behavior, error handling, and common mistakes. diff --git a/website/docs/user-guide/secrets/index.md b/website/docs/user-guide/secrets/index.md index 6e14b2d38460..2e39a31057c8 100644 --- a/website/docs/user-guide/secrets/index.md +++ b/website/docs/user-guide/secrets/index.md @@ -29,6 +29,6 @@ Every credential injected by a source is labelled with its origin — setup flow ## Adding your own backend -Third-party secret managers ship as standalone plugins, not core PRs. A backend subclasses `agent.secret_sources.base.SecretSource` (one required method: `fetch(cfg, home_path) -> FetchResult`) and registers via `ctx.register_secret_source(MySource())` in the plugin's `register(ctx)`. The orchestrator owns precedence, conflict handling, timeouts, and provenance — your source only fetches. Contract rules: `fetch()` never raises, never prompts, and returns within its timeout budget; validate your implementation against the conformance kit in `tests/secret_sources/conformance.py`. +Third-party secret managers ship as standalone plugins, not core PRs. A backend subclasses `agent.secret_sources.base.SecretSource` (one required method: `fetch(cfg, home_path) -> FetchResult`) and registers via `ctx.register_secret_source(MySource())` in the plugin's `register(ctx)`. The orchestrator owns precedence, conflict handling, timeouts, and provenance — your source only fetches. Full guide with the contract rules, subprocess-safety helper, and conformance kit: [Building a Secret Source Plugin](/developer-guide/secret-source-plugin). The bundled set is deliberately closed (same policy as memory providers): Bitwarden and 1Password ship in-tree. Everything else — Infisical, Proton Pass, HashiCorp Vault, AWS Secrets Manager, OS keystores — belongs in plugin repos; share them in the Nous Research Discord (`#plugins-skills-and-skins`). diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 6b2e35aa8645..6243170a6b91 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -78,6 +78,12 @@ const config: Config = { from: '/guides/automation-templates', to: '/guides/automation-blueprints', }, + { + // Moved when the Plugins subcategory was created under + // Developer Guide > Extending (docs restructure, July 2026) + from: '/guides/build-a-hermes-plugin', + to: '/developer-guide/plugins', + }, ], }, ], diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-tools.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-tools.md index 21aaff76ca87..bc9eb457ec89 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-tools.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-tools.md @@ -13,7 +13,7 @@ description: "如何向 Hermes Agent 添加新工具——schema、handler、注 如果你想要个人专用、项目本地或其他自定义工具,而不修改 Hermes 核心,请使用插件方式: - [插件](/user-guide/features/plugins) -- [构建 Hermes 插件](/guides/build-a-hermes-plugin) +- [构建 Hermes 插件](/developer-guide/plugins) 大多数自定义工具创建场景默认使用插件。只有当你明确希望在 `tools/` 和 `toolsets.py` 中发布新的内置工具时,才遵循本页面。 ::: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/architecture.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/architecture.md index f5c6c71ffbb2..5b3ba00aa3b9 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/architecture.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/architecture.md @@ -231,7 +231,7 @@ CLI、gateway、cron、ACP 及辅助调用共用的运行时解析器。将 `(pr 三种发现来源:`~/.hermes/plugins/`(用户级)、`.hermes/plugins/`(项目级)和 pip entry point。插件通过上下文 API 注册工具、hook 和 CLI 命令。存在两种专用插件类型:记忆提供者(`plugins/memory/`)和上下文引擎(`plugins/context_engine/`)。两者均为单选——每种同时只能激活一个,通过 `hermes plugins` 或 `config.yaml` 配置。 -→ [插件指南](/guides/build-a-hermes-plugin),[记忆提供者插件](./memory-provider-plugin.md) +→ [插件指南](/developer-guide/plugins),[记忆提供者插件](./memory-provider-plugin.md) ### Cron diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/contributing.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/contributing.md index 773017012a64..8a4547f35d5f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/contributing.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/contributing.md @@ -22,7 +22,7 @@ description: "如何为 Hermes Agent 做贡献 — 开发环境配置、代码 ## 常见贡献路径 -- 构建自定义/本地工具而不修改 Hermes 核心?从 [构建 Hermes 插件](../guides/build-a-hermes-plugin.md) 开始 +- 构建自定义/本地工具而不修改 Hermes 核心?从 [构建 Hermes 插件](../developer-guide/plugins/index.md) 开始 - 为 Hermes 本身构建新的内置核心工具?从 [添加工具](./adding-tools.md) 开始 - 构建新的 skill?从 [创建 Skill](./creating-skills.md) 开始 - 构建新的推理提供商?从 [添加提供商](./adding-providers.md) 开始 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/image-gen-provider-plugin.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/image-gen-provider-plugin.md index 66bdcd1e5424..60dc44bc3176 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/image-gen-provider-plugin.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/image-gen-provider-plugin.md @@ -9,7 +9,7 @@ description: "如何为 Hermes Agent 构建图像生成后端插件" 图像生成 provider 插件注册一个后端,用于处理所有 `image_generate` 工具调用——DALL·E、gpt-image、Grok、Flux、Imagen、Stable Diffusion、fal、Replicate、本地 ComfyUI 装置,任何后端均可。内置 provider(OpenAI、OpenAI-Codex、xAI)均以插件形式提供。你可以通过在 `plugins/image_gen//` 目录下放置一个目录来添加新的 provider,或覆盖内置 provider。 :::tip -图像生成是 Hermes 支持的多种**后端插件**之一。其他插件(各有更专用的 ABC)包括:[Memory Provider 插件](/developer-guide/memory-provider-plugin)、[Context Engine 插件](/developer-guide/context-engine-plugin) 和 [Model Provider 插件](/developer-guide/model-provider-plugin)。通用工具/hook/CLI 插件请参阅 [构建 Hermes 插件](/guides/build-a-hermes-plugin)。 +图像生成是 Hermes 支持的多种**后端插件**之一。其他插件(各有更专用的 ABC)包括:[Memory Provider 插件](/developer-guide/memory-provider-plugin)、[Context Engine 插件](/developer-guide/context-engine-plugin) 和 [Model Provider 插件](/developer-guide/model-provider-plugin)。通用工具/hook/CLI 插件请参阅 [构建 Hermes 插件](/developer-guide/plugins)。 ::: ## 发现机制 @@ -279,10 +279,10 @@ hermes -z "Generate an image of a corgi in a spacesuit" my-backend-imggen = "my_backend_imggen_package" ``` -`my_backend_imggen_package` 必须暴露一个顶层 `register` 函数。完整配置请参阅通用插件指南中的 [通过 pip 分发](/guides/build-a-hermes-plugin#distribute-via-pip)。 +`my_backend_imggen_package` 必须暴露一个顶层 `register` 函数。完整配置请参阅通用插件指南中的 [通过 pip 分发](/developer-guide/plugins#distribute-via-pip)。 ## 相关页面 - [图像生成](/user-guide/features/image-generation) — 面向用户的功能文档 - [插件概览](/user-guide/features/plugins) — 所有插件类型一览 -- [构建 Hermes 插件](/guides/build-a-hermes-plugin) — 通用工具/hook/斜杠命令指南 \ No newline at end of file +- [构建 Hermes 插件](/developer-guide/plugins) — 通用工具/hook/斜杠命令指南 \ No newline at end of file diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/model-provider-plugin.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/model-provider-plugin.md index e649fe5d23af..3f9829c80af4 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/model-provider-plugin.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/model-provider-plugin.md @@ -256,7 +256,7 @@ acme-inference = "acme_hermes_plugin:register" ……其中 `acme_hermes_plugin:register` 是一个调用 `register_provider(profile)` 的函数。通用 PluginManager 在 `discover_and_load()` 期间会拾取入口点插件。对于 `kind: model-provider` 的 pip 插件,你仍需在 manifest 中声明 kind(或依赖源码文本启发式检测)。 -完整的入口点设置请参阅 [构建 Hermes 插件](/guides/build-a-hermes-plugin#distribute-via-pip)。 +完整的入口点设置请参阅 [构建 Hermes 插件](/developer-guide/plugins#distribute-via-pip)。 ## 相关页面 @@ -264,4 +264,4 @@ acme-inference = "acme_hermes_plugin:register" - [添加提供商](/developer-guide/adding-providers) — 新推理后端的端到端检查清单(涵盖快速插件路径和完整 CLI/auth 集成) - [Memory Provider 插件](/developer-guide/memory-provider-plugin) - [Context Engine 插件](/developer-guide/context-engine-plugin) -- [构建 Hermes 插件](/guides/build-a-hermes-plugin) — 通用插件编写指南 \ No newline at end of file +- [构建 Hermes 插件](/developer-guide/plugins) — 通用插件编写指南 \ No newline at end of file diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/build-a-hermes-plugin.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md similarity index 99% rename from website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/build-a-hermes-plugin.md rename to website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md index 19b77da25783..8ffeab09209c 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/build-a-hermes-plugin.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md @@ -1,5 +1,4 @@ --- -sidebar_position: 9 sidebar_label: "Build a Plugin" title: "构建 Hermes 插件" description: "逐步指南:构建包含工具、钩子、数据文件和技能的完整 Hermes 插件" diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/web-search-provider-plugin.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/web-search-provider-plugin.md index 739501b0376b..6d69c569f899 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/web-search-provider-plugin.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/web-search-provider-plugin.md @@ -9,7 +9,7 @@ description: "如何为 Hermes Agent 构建网页搜索/提取/爬取后端插 网页搜索提供商插件注册一个后端,用于处理 `web_search`、`web_extract` 以及(可选的)深度爬取工具调用。内置提供商——Firecrawl、SearXNG、Tavily、Exa、Parallel、Brave Search(免费层)和 DDGS——均以插件形式存放于 `plugins/web//` 目录下。你可以在该目录旁新建一个目录来添加新提供商,或覆盖已有的内置提供商。 :::tip -网页搜索是 Hermes 支持的多种**后端插件**之一。其他插件(各有其 ABC)包括:[图像生成提供商插件](/developer-guide/image-gen-provider-plugin)、[视频生成提供商插件](/developer-guide/video-gen-provider-plugin)、[记忆提供商插件](/developer-guide/memory-provider-plugin)、[上下文引擎插件](/developer-guide/context-engine-plugin)和[模型提供商插件](/developer-guide/model-provider-plugin)。通用工具/hook/CLI 插件请参阅[构建 Hermes 插件](/guides/build-a-hermes-plugin)。 +网页搜索是 Hermes 支持的多种**后端插件**之一。其他插件(各有其 ABC)包括:[图像生成提供商插件](/developer-guide/image-gen-provider-plugin)、[视频生成提供商插件](/developer-guide/video-gen-provider-plugin)、[记忆提供商插件](/developer-guide/memory-provider-plugin)、[上下文引擎插件](/developer-guide/context-engine-plugin)和[模型提供商插件](/developer-guide/model-provider-plugin)。通用工具/hook/CLI 插件请参阅[构建 Hermes 插件](/developer-guide/plugins)。 ::: ## 发现机制 @@ -141,7 +141,7 @@ requires_env: |---|---| | `kind: backend` | 将插件路由至后端加载路径 | | `provides_web_providers` | 该插件注册的提供商 `name` 列表——在 `register()` 运行之前,加载器即可通过此字段在 `hermes tools` 中公示插件 | -| `requires_env` | 在 `hermes plugins install` 期间进行交互式凭据提示(富格式说明参见[构建 Hermes 插件](/guides/build-a-hermes-plugin#gate-on-environment-variables)) | +| `requires_env` | 在 `hermes plugins install` 期间进行交互式凭据提示(富格式说明参见[构建 Hermes 插件](/developer-guide/plugins#gate-on-environment-variables)) | ## ABC 参考 @@ -233,7 +233,7 @@ web: ## 懒加载可选依赖 -如果你的提供商封装了第三方 SDK(如 DDGS 封装了 `ddgs` 包),请勿在模块顶层 `import`。在 `is_available()` 或 `search()` 内部使用 `tools.lazy_deps.ensure(...)` ——Hermes 将在首次使用时安装该包,并受 `security.allow_lazy_installs` 控制。安全模型详见[构建 Hermes 插件 → 懒加载](/guides/build-a-hermes-plugin#lazy-install-optional-python-dependencies)。 +如果你的提供商封装了第三方 SDK(如 DDGS 封装了 `ddgs` 包),请勿在模块顶层 `import`。在 `is_available()` 或 `search()` 内部使用 `tools.lazy_deps.ensure(...)` ——Hermes 将在首次使用时安装该包,并受 `security.allow_lazy_installs` 控制。安全模型详见[构建 Hermes 插件 → 懒加载](/developer-guide/plugins#lazy-install-optional-python-dependencies)。 ## 参考实现 @@ -251,10 +251,10 @@ web: my-backend-web = "my_backend_web_package" ``` -`my_backend_web_package` 必须暴露顶层 `register` 函数。完整配置说明参见通用插件指南中的[通过 pip 分发](/guides/build-a-hermes-plugin#distribute-via-pip)。 +`my_backend_web_package` 必须暴露顶层 `register` 函数。完整配置说明参见通用插件指南中的[通过 pip 分发](/developer-guide/plugins#distribute-via-pip)。 ## 相关页面 - [网页搜索](/user-guide/features/web-search) — 面向用户的功能文档及各后端配置说明 - [插件概览](/user-guide/features/plugins) — 所有插件类型一览 -- [构建 Hermes 插件](/guides/build-a-hermes-plugin) — 通用工具/hook/斜杠命令指南 \ No newline at end of file +- [构建 Hermes 插件](/developer-guide/plugins) — 通用工具/hook/斜杠命令指南 \ No newline at end of file diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/learning-path.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/learning-path.md index 4d2443d23e43..fe6bb448ff6c 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/learning-path.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/learning-path.md @@ -81,7 +81,7 @@ Cron 任务让 Hermes Agent 按计划执行任务——每日摘要、定期检 通过自定义工具和可复用技能包扩展 Hermes Agent。 1. [插件](/user-guide/features/plugins) -2. [构建 Hermes 插件](/guides/build-a-hermes-plugin) +2. [构建 Hermes 插件](/developer-guide/plugins) 3. [工具概览](/user-guide/features/tools) 4. [技能概览](/user-guide/features/skills) 5. [MCP(模型上下文协议)](/user-guide/features/mcp) diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/github-pr-review-agent.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/github-pr-review-agent.md index b842be69d9ab..d2e6e8cf7c7e 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/github-pr-review-agent.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/github-pr-review-agent.md @@ -298,6 +298,6 @@ GitHub 对已认证用户每小时允许 5,000 次 API 请求。每次 PR 审查 - **[基于 Webhook 的 PR 审查](./webhook-github-pr-review.md)** — 在 PR 被打开时立即获得审查(需要公开端点) - **[每日简报 Bot](/guides/daily-briefing-bot)** — 将 PR 审查与你的晨间资讯摘要结合 -- **[构建 Plugin](/guides/build-a-hermes-plugin)** — 将审查逻辑封装为可共享的 plugin +- **[构建 Plugin](/developer-guide/plugins)** — 将审查逻辑封装为可共享的 plugin - **[Profiles](/user-guide/profiles)** — 运行一个专属审查器 profile,拥有独立的 memory 和配置 - **[Fallback Providers](/user-guide/features/fallback-providers)** — 确保在某个 provider 不可用时审查任务仍能正常运行 \ No newline at end of file diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/webhook-github-pr-review.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/webhook-github-pr-review.md index 6fdc332dedfa..788deb3b85ba 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/webhook-github-pr-review.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/webhook-github-pr-review.md @@ -325,5 +325,5 @@ platforms: - **[基于 Cron 的 PR 审查](./github-pr-review-agent.md)** —— 按计划轮询 PR,无需公网端点 - **[Webhook 参考](/user-guide/messaging/webhooks)** —— webhook 平台的完整配置参考 -- **[构建 Plugin](/guides/build-a-hermes-plugin)** —— 将审查逻辑打包为可共享的 plugin +- **[构建 Plugin](/developer-guide/plugins)** —— 将审查逻辑打包为可共享的 plugin - **[Profiles](/user-guide/profiles)** —— 运行一个拥有独立内存和配置的专属审查者 profile \ No newline at end of file diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/work-with-skills.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/work-with-skills.md index 3a16885b1277..a443fab9915a 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/work-with-skills.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/work-with-skills.md @@ -135,7 +135,7 @@ skill_view("writing-plans") 插件 skills **不会**列在系统 prompt 中,也不出现在 `skills_list` 中。它们是按需加载的——当你知道某个插件提供了某个 skill 时,显式加载它。加载后,agent 会看到一个横幅,列出同一插件的其他 skills。 -关于如何在自己的插件中捆绑 skills,请参见 [构建 Hermes 插件 → 捆绑 skills](/guides/build-a-hermes-plugin#bundle-skills)。 +关于如何在自己的插件中捆绑 skills,请参见 [构建 Hermes 插件 → 捆绑 skills](/developer-guide/plugins#bundle-skills)。 --- diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/index.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/index.md index 39ca64095d19..3906102e00db 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/index.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/index.md @@ -93,7 +93,7 @@ Hermes 可作为 gateway(网关)机器人运行于 19+ 个消息平台,均 ## 插件 - **[插件系统](/user-guide/features/plugins)** — 无需修改核心代码,通过自定义工具、生命周期 hook(钩子)和 CLI 命令扩展 Hermes。插件从 `~/.hermes/plugins/`、项目本地 `.hermes/plugins/` 以及通过 pip 安装的入口点自动发现。 -- **[构建插件](/guides/build-a-hermes-plugin)** — 创建包含工具、hook 和 CLI 命令的 Hermes 插件的分步指南。 +- **[构建插件](/developer-guide/plugins)** — 创建包含工具、hook 和 CLI 命令的 Hermes 插件的分步指南。 ## 训练与评估 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md index 8c2a82169372..5c6a85144ff1 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md @@ -1026,7 +1026,7 @@ Provider plugin 选择保存到 `config.yaml`: 通用 plugin 禁用列表存储在 `config.yaml` 的 `plugins.disabled` 下。 -参见 [Plugins](../user-guide/features/plugins.md) 和 [构建 Hermes Plugin](../guides/build-a-hermes-plugin.md)。 +参见 [Plugins](../user-guide/features/plugins.md) 和 [构建 Hermes Plugin](../developer-guide/plugins/index.md)。 ## `hermes tools` diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/built-in-plugins.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/built-in-plugins.md index 834b28b6c0a2..5ac19fae8f12 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/built-in-plugins.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/built-in-plugins.md @@ -9,7 +9,7 @@ description: "随 Hermes Agent 附带并通过生命周期 hook 自动运行的 Hermes 随仓库附带了一小组插件。它们位于 `/plugins//`,与用户安装在 `~/.hermes/plugins/` 中的插件一同自动加载。它们使用与第三方插件相同的插件接口——hook、工具、斜杠命令——只是在仓库内维护。 -请参阅 [插件](/user-guide/features/plugins) 页面了解通用插件系统,以及 [构建 Hermes 插件](/guides/build-a-hermes-plugin) 了解如何编写自己的插件。 +请参阅 [插件](/user-guide/features/plugins) 页面了解通用插件系统,以及 [构建 Hermes 插件](/developer-guide/plugins) 了解如何编写自己的插件。 ## 发现机制 @@ -253,7 +253,7 @@ agent 会启动会议加入流程,在通话进行时将转录内容流式传 ## 添加内置插件 -内置插件的编写方式与其他 Hermes 插件完全相同——参见 [构建 Hermes 插件](/guides/build-a-hermes-plugin)。唯一的区别是: +内置插件的编写方式与其他 Hermes 插件完全相同——参见 [构建 Hermes 插件](/developer-guide/plugins)。唯一的区别是: - 目录位于 `/plugins//`,而非 `~/.hermes/plugins//` - 在 `hermes plugins list` 中,manifest 来源显示为 `bundled` diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/hooks.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/hooks.md index c81e84956fbf..85265ecfd213 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/hooks.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/hooks.md @@ -801,7 +801,7 @@ def my_callback(session_id: str, platform: str, **kwargs): --- -参见 **[构建插件指南](/guides/build-a-hermes-plugin)**,获取包含工具 schema、处理器和高级 hook 模式的完整演练。 +参见 **[构建插件指南](/developer-guide/plugins)**,获取包含工具 schema、处理器和高级 hook 模式的完整演练。 --- diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/plugins.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/plugins.md index 12a83f2a6d02..7e8aa776b82f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/plugins.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/plugins.md @@ -12,7 +12,7 @@ Hermes 提供了一套插件系统,可在不修改核心代码的情况下添 如果你想为自己、团队或某个项目创建自定义工具,这通常是正确的路径。开发者指南中的 [Adding Tools](/developer-guide/adding-tools) 页面针对的是存放在 `tools/` 和 `toolsets.py` 中的 Hermes 内置核心工具。 -**→ [构建 Hermes Plugin](/guides/build-a-hermes-plugin)** — 包含完整可运行示例的分步指南。 +**→ [构建 Hermes Plugin](/developer-guide/plugins)** — 包含完整可运行示例的分步指南。 ## 快速概览 @@ -221,9 +221,9 @@ Memory provider 和 context engine 是 **provider 插件** — 每种类型同 | 想要添加… | 方式 | 编写指南 | |---|---|---| -| LLM 可调用的**工具** | Python 插件 — `ctx.register_tool()` | [Build a Hermes Plugin](/guides/build-a-hermes-plugin) · [Adding Tools](/developer-guide/adding-tools) | -| **生命周期 hook**(LLM 前后、会话开始/结束、工具过滤) | Python 插件 — `ctx.register_hook()` | [Hooks reference](/user-guide/features/hooks) · [Build a Hermes Plugin](/guides/build-a-hermes-plugin) | -| CLI / gateway 的**斜杠命令** | Python 插件 — `ctx.register_command()` | [Build a Hermes Plugin](/guides/build-a-hermes-plugin) · [Extending the CLI](/developer-guide/extending-the-cli) | +| LLM 可调用的**工具** | Python 插件 — `ctx.register_tool()` | [Build a Hermes Plugin](/developer-guide/plugins) · [Adding Tools](/developer-guide/adding-tools) | +| **生命周期 hook**(LLM 前后、会话开始/结束、工具过滤) | Python 插件 — `ctx.register_hook()` | [Hooks reference](/user-guide/features/hooks) · [Build a Hermes Plugin](/developer-guide/plugins) | +| CLI / gateway 的**斜杠命令** | Python 插件 — `ctx.register_command()` | [Build a Hermes Plugin](/developer-guide/plugins) · [Extending the CLI](/developer-guide/extending-the-cli) | | `hermes ` 的**子命令** | Python 插件 — `ctx.register_cli_command()` | [Extending the CLI](/developer-guide/extending-the-cli) | | 插件附带的**skill** | Python 插件 — `ctx.register_skill()` | [Creating Skills](/developer-guide/creating-skills) | | **推理后端**(LLM provider:OpenAI 兼容、Codex、Anthropic-Messages、Bedrock) | Provider 插件 — 在 `plugins/model-providers//` 中调用 `register_provider(ProviderProfile(...))` | **[Model Provider Plugins](/developer-guide/model-provider-plugin)** · [Adding Providers](/developer-guide/adding-providers) | @@ -347,4 +347,4 @@ ctx.inject_message("New data arrived from the webhook", role="user") `inject_message` 仅在 CLI 模式下可用。在 gateway 模式下,没有 CLI 引用,该方法返回 `False`。 ::: -完整的处理器约定、schema 格式、hook 行为、错误处理和常见错误请参见 **[完整指南](/guides/build-a-hermes-plugin)**。 \ No newline at end of file +完整的处理器约定、schema 格式、hook 行为、错误处理和常见错误请参见 **[完整指南](/developer-guide/plugins)**。 \ No newline at end of file diff --git a/website/sidebars.ts b/website/sidebars.ts index b012354a532e..189406e25994 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -37,6 +37,7 @@ const sidebars: SidebarsConfig = { items: [ 'user-guide/secrets/index', 'user-guide/secrets/bitwarden', + 'user-guide/secrets/onepassword', ], }, 'user-guide/sessions', @@ -690,7 +691,6 @@ const sidebars: SidebarsConfig = { 'guides/use-mcp-with-hermes', 'guides/use-soul-with-hermes', 'guides/use-voice-mode-with-hermes', - 'guides/build-a-hermes-plugin', 'guides/automate-with-cron', 'guides/cron-script-only', 'guides/automation-blueprints', @@ -736,13 +736,21 @@ const sidebars: SidebarsConfig = { 'developer-guide/adding-tools', 'developer-guide/adding-providers', 'developer-guide/adding-platform-adapters', - 'developer-guide/memory-provider-plugin', - 'developer-guide/context-engine-plugin', - 'developer-guide/model-provider-plugin', - 'developer-guide/image-gen-provider-plugin', - 'developer-guide/video-gen-provider-plugin', - 'developer-guide/web-search-provider-plugin', - 'developer-guide/plugin-llm-access', + { + type: 'category', + label: 'Plugins', + link: {type: 'doc', id: 'developer-guide/plugins/index'}, + items: [ + 'developer-guide/plugin-llm-access', + 'developer-guide/memory-provider-plugin', + 'developer-guide/context-engine-plugin', + 'developer-guide/secret-source-plugin', + 'developer-guide/model-provider-plugin', + 'developer-guide/image-gen-provider-plugin', + 'developer-guide/video-gen-provider-plugin', + 'developer-guide/web-search-provider-plugin', + ], + }, 'developer-guide/creating-skills', 'developer-guide/extending-the-cli', ], From 2ebf9a90b762f21e33318b342e921b17e3d81946 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Jul 2026 05:07:51 -0700 Subject: [PATCH 010/610] =?UTF-8?q?refactor(skills):=20finish=20shop-app?= =?UTF-8?q?=E2=86=92shop=20rename=20in=20zh-Hans=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The English-side rename from #38138 already landed on main; this carries the remaining zh-Hans i18n catalog + doc-page rename so the localized docs match the skill's canonical name. --- .../current/reference/optional-skills-catalog.md | 2 +- ...productivity-shop-app.md => productivity-shop.md} | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) rename website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/productivity/{productivity-shop-app.md => productivity-shop.md} (98%) diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/optional-skills-catalog.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/optional-skills-catalog.md index aed044b30995..297182ece5ed 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/optional-skills-catalog.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/optional-skills-catalog.md @@ -152,7 +152,7 @@ hermes skills uninstall | [**canvas**](/user-guide/skills/optional/productivity/productivity-canvas) | Canvas LMS 集成 — 使用 API token 认证获取已注册课程和作业。 | | [**here.now**](/user-guide/skills/optional/productivity/productivity-here-now) | 将静态站点发布至 {slug}.here.now,并将私有文件存储在云端 Drive 中以供 agent 间交接。 | | [**memento-flashcards**](/user-guide/skills/optional/productivity/productivity-memento-flashcards) | 间隔重复闪卡系统。从事实或文本创建卡片,通过 agent 评分的自由文本回答与闪卡对话,从 YouTube 字幕生成测验,使用自适应调度复习到期卡片,并支持导出/导入。 | -| [**shop-app**](/user-guide/skills/optional/productivity/productivity-shop-app) | Shop.app:商品搜索、订单追踪、退货、重新下单。 | +| [**shop**](/user-guide/skills/optional/productivity/productivity-shop) | Shop.app:商品搜索、订单追踪、退货、重新下单。 | | [**shopify**](/user-guide/skills/optional/productivity/productivity-shopify) | 通过 curl 使用 Shopify Admin 和 Storefront GraphQL API。支持商品、订单、客户、库存、元字段。 | | [**siyuan**](/user-guide/skills/optional/productivity/productivity-siyuan) | 通过 curl 使用 SiYuan Note API,在自托管知识库中搜索、读取、创建和管理块与文档。 | | [**telephony**](/user-guide/skills/optional/productivity/productivity-telephony) | 为 Hermes 添加电话能力,无需修改核心工具。配置并持久化 Twilio 号码,发送和接收 SMS/MMS,拨打直接通话,并通过 Bland.ai 或 Vapi 发起 AI 驱动的外呼。 | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/productivity/productivity-shop-app.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/productivity/productivity-shop.md similarity index 98% rename from website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/productivity/productivity-shop-app.md rename to website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/productivity/productivity-shop.md index ae48cdbfcb6c..9b256426038a 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/productivity/productivity-shop-app.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/productivity/productivity-shop.md @@ -1,12 +1,12 @@ --- -title: "Shop App — Shop" -sidebar_label: "Shop App" -description: "Shop" +title: "Shop — Shop.app:商品搜索、订单追踪、退货、重新下单" +sidebar_label: "Shop" +description: "Shop.app:商品搜索、订单追踪、退货、重新下单" --- {/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} -# Shop App +# Shop Shop.app:商品搜索、订单追踪、退货、重新下单。 @@ -14,8 +14,8 @@ Shop.app:商品搜索、订单追踪、退货、重新下单。 | | | |---|---| -| 来源 | 可选 — 使用 `hermes skills install official/productivity/shop-app` 安装 | -| 路径 | `optional-skills/productivity/shop-app` | +| 来源 | 可选 — 使用 `hermes skills install official/productivity/shop` 安装 | +| 路径 | `optional-skills/productivity/shop` | | 版本 | `0.0.28` | | 作者 | community | | 许可证 | MIT | From 5e5191b9faeaf2ea6aac64fa5fe6d753fc95e0f0 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:31:25 -0700 Subject: [PATCH 011/610] feat(skills): add dynamic-workflow orchestration skill Adapts Claude Code's research-preview dynamic workflows (plan-in-code fan-out, hundreds of subagents per session) to Hermes invariants. The ported mechanic is plan/loop/intermediate-state-out-of-context, not more subagents. Documents the two real orchestration layers and the hard capability boundary between them: - Layer A (execute_code): deterministic fan-out, SANDBOX_ALLOWED_TOOLS only, cannot call delegate_task - Layer B (delegate_task batch): LLM-judgment fan-out Plus the synchronous trap (delegate_task is turn-scoped, cancelled on new message; durable/resumable = kanban swarm) and the genuinely-new piece: the adversarial-convergence verification recipe (N independent attempts with varied framings + M refuters, keep only located claims that survive refutation, iterate to convergence). Self-contained: inlines the load-bearing fan-out hygiene rather than hard-depending on local-only skills; references the shipped kanban swarm subsystem for the durable path. --- .../dynamic-workflow/SKILL.md | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 skills/autonomous-ai-agents/dynamic-workflow/SKILL.md diff --git a/skills/autonomous-ai-agents/dynamic-workflow/SKILL.md b/skills/autonomous-ai-agents/dynamic-workflow/SKILL.md new file mode 100644 index 000000000000..0a35fc106b69 --- /dev/null +++ b/skills/autonomous-ai-agents/dynamic-workflow/SKILL.md @@ -0,0 +1,191 @@ +--- +name: dynamic-workflow +description: Orchestrate large fan-out work as a plan-in-code "workflow" so the agent's context holds only the final verified answer, not the exhaust of hundreds of intermediate steps. Use for codebase-wide sweeps, large migrations, multi-angle research, and any task too big for one context window where the split strategy is known enough to script. Includes the adversarial-convergence verification recipe (independent attempts + refuters, keep only surviving claims). +version: 1.0.0 +author: Hermes Agent + Teknium +license: MIT +metadata: + hermes: + tags: [orchestration, fan-out, subagents, delegation, verification, migration, audit, research] + category: autonomous-ai-agents + related_skills: [] +when_to_use: + - A task is too big for one context window AND you can describe the split (per-file, per-endpoint, per-source, per-record) + - You want orchestration codified as a re-runnable script, not improvised turn-by-turn + - Quality matters more than token economy: you want independent attempts cross-checked / refuted before you trust the answer + - Codebase-wide bug/security sweep, 100+ file migration, multi-angle research with sources cross-checked +when_not_to_use: + - Small bounded task (<~10 units) — just call the tool directly or do it inline + - Tight serial dependency (B needs A's output) — orchestration overhead is wasted + - You need it to survive the user sending a new message — see "The synchronous trap" below; use cron/kanban instead +--- + +# Dynamic Workflow — plan-in-code fan-out with verification + +This is Hermes's answer to Claude Code's "dynamic workflows" (run hundreds of +parallel subagents in one session). The mechanic worth copying is NOT "more +subagents" — it is **moving the plan, the loop, and the intermediate results +OUT of the context window and INTO a script.** Normally the agent IS the +orchestrator: every intermediate result piles into context, which is exactly +what caps you at a handful of agents. A workflow keeps only the *final verified +answer* in context; the script holds everything else. + +> This skill is self-contained, but it builds on standard fan-out hygiene — +> chunk inputs to ~50-70KB per child, route structured output to files (not the +> `summary` field, which truncates under load), use delimiter-separated lines +> over JSON wrappers, and remember that a "stalled" child often completed its +> write anyway (check the filesystem before retrying). If your install has a +> `delegate-task-output-patterns` skill, load it for the detailed thresholds; +> the rules above are the load-bearing subset. + +## The two orchestration-script layers (pick the right one — they are NOT interchangeable) + +Hermes has no JS runtime. The "orchestration script" is one of two layers, and +the split is enforced by a real capability boundary, not a style preference: + +| | Layer A: `execute_code` (Python script) | Layer B: `delegate_task` batch | +|---|---|---| +| Use for | DETERMINISTIC fan-out — fetch N URLs, parse N files, run N shell commands, template N outputs | LLM-JUDGMENT fan-out — classify, review, decide, write, refute, audit per item | +| The script holds | the loop + branching + intermediate vars (real Python) | n/a — you call it once with a `tasks=[...]` array; each task is its own isolated agent | +| Tools available inside | `web_search, web_extract, read_file, write_file, search_files, terminal, patch` ONLY (the `SANDBOX_ALLOWED_TOOLS` set) | full toolsets per child (configurable) | +| Can it call `delegate_task`? | **NO.** `delegate_task` is NOT in `SANDBOX_ALLOWED_TOOLS`. Do not write a script that imports it — it will fail. | itself, if `role='orchestrator'` and `max_spawn_depth>=2` | +| Concurrency | you control it in Python (`ThreadPoolExecutor`, batches) | `delegation.max_concurrent_children` (default 3; raise in config.yaml) | +| Cost shape | cheap — most steps are tool calls, no per-item LLM unless you call `web_search`/aux | one model call tree PER child task — multiplies linearly, can be very expensive | + +**Rule of thumb:** do the deterministic part in Layer A first (inline, in a +script), then fan out ONLY the irreducibly-LLM step via Layer B. This is +Pattern 1 from `delegate-task-output-patterns`, applied at workflow scale. +Mixing them: a Layer-A script can write a manifest file, and you (the parent) +then read that manifest and issue a single Layer-B `delegate_task` batch. + +## The synchronous trap (READ THIS — it is the #1 way a "workflow" disappoints) + +`delegate_task` runs **synchronously inside the parent turn**. If the user sends +a new message, hits /stop, or /new, every in-flight child is **cancelled and its +work discarded** (status `interrupted`). It does NOT run in the background, and +it does NOT survive the turn. There is no cache-resume of a half-finished fan-out. + +So a "workflow" in Hermes is one of: + +1. **Foreground workflow (default):** Layer A and/or one Layer-B batch, completed + within a single turn. Good for minutes-long fan-out (dozens of units). The + user waits. This is what you build 90% of the time. +2. **Durable workflow (hours/days, survives interruption):** use the **kanban + swarm** (the SQLite-backed multi-agent kernel that ships with Hermes — + `hermes_cli/kanban_swarm.py` + the kanban plugin; if your install has a + `kanban-multiagent` skill, load it for the workflow). It + writes a task graph (root → parallel workers → verifier → synthesizer) into + the SQLite kanban kernel with a JSON blackboard. State persists across turns + and restarts. This is the ONLY path that matches Claude Code's "runs into + hours and days, resumes where it left off." Reach for it when the foreground + path would time out or when the user must be able to walk away. + +Never promise "background, resumable, hundreds of agents over days" from a plain +`delegate_task` call. That is the kanban path or nothing. + +## Workflow recipe (foreground) + +1. **Decompose into independent units.** What is the unit — a file? an endpoint? + a source? a record? Each unit must be answerable WITHOUT the others' output + (else it's serial, not fan-out — see when_not_to_use). +2. **Deterministic pre-pass (Layer A).** In one `execute_code` script, gather the + manifest: list the files, extract the candidate sites, fetch the raw sources, + compute anything regex/parse can compute. Write a manifest to + `/tmp/wf_/manifest.jsonl` (one unit per line). This is the "plan in + code." Print a count and stop. +3. **Size the fan-out** against `delegate-task-output-patterns`: chunk so each + child handles ~8-12 mechanical file edits OR ~2000-3000 lines of reading OR + ~50-70KB of corpus. Look at the LARGEST unit, not the average. +4. **LLM-judgment fan-out (Layer B).** Issue ONE `delegate_task` with a `tasks=[]` + array, one task per chunk. Each task: reads its slice from the manifest, + emits delimiter-separated lines to `/tmp/wf_/out_.csv`, prints a + status word, stops. Do NOT depend on the `summary` field for content. +5. **Synthesize on the parent.** Read the out_*.csv files yourself, merge, + present. The cross-cutting "whole picture" step stays on the parent — only the + per-unit work fanned out. + +## The novel mechanic worth building: adversarial convergence + +This is the part Hermes did NOT already have and the real reason to bother. +Claude Code's quality claim ("independent agents try to refute each other's +findings; only surviving claims surface; iterate until they converge") maps +cleanly onto `delegate_task` batch mode: + +### Recipe: N independent attempts + M refuters + +For a finding-quality task (security audit, "is this code path actually +vulnerable?", "does this migration preserve behavior?", a high-stakes plan): + +1. **Independent attempts (round 1).** Fan out the SAME question to N children + (N=2-4) with DIFFERENT framings/angles in each `context`, so they don't + collapse to the same reasoning. Each writes its claims to + `/tmp/wf_/attempt_.md` as a list of discrete, individually-checkable + claims (one claim per line — atomicity is what makes refutation possible). +2. **Collect + dedupe (parent or Layer A).** Merge all claims into a single + numbered list. Identical claims from independent attempts = higher prior; + note the agreement count per claim. +3. **Refutation round (round 2).** Fan out a refuter batch: each refuter gets the + claim list and is told "your job is to BREAK these claims — for each, find the + counter-evidence (the auth check that DOES exist, the test that DOES cover it, + the edge case the claim ignores). Output `claim_idx|survives|counter_evidence`." + Give refuters the codebase/sources, not the original attempts' reasoning. +4. **Keep only survivors.** A claim surfaces to the user only if it survived + refutation (no refuter produced valid counter-evidence). Filtered claims are + dropped, with a one-line note of why if the user asked for completeness. +5. **Converge (optional).** If round 2 surfaced NEW claims (refuters often find + adjacent issues), feed them back through one more refutation round. Stop when + a round produces no new surviving claims — that's convergence. Cap at 3 rounds + to bound cost. + +This gives you the "more trustworthy than a single pass" property without a +runtime — it's just two `delegate_task` batches and a merge, structured so +disagreement is visible and unsupported claims die before they reach the user. + +### Why atomic claims matter +A refuter cannot break "the auth layer has problems." It CAN break "endpoint +`POST /api/users/:id/role` in src/routes/users.ts:142 has no role check." Force +attempts to emit specific, located, individually-falsifiable claims or the +refutation round is theater. + +## Cost discipline (this is the thing that bites) + +A workflow can consume dramatically more tokens than a normal turn — that is +inherent, not a bug. Two real multipliers: + +- **Each Layer-B child is a full agent tree.** 20 children ≈ 20× the model calls. + `delegation.max_concurrent_children` only bounds *concurrency*, not *total*. +- **Hermes aux/subagent model defaults to main-model-first.** Children inherit + the parent's (often expensive reasoning) model unless you pin a cheaper one. + For mechanical fan-out, pass a cheaper model per task if the config supports it, + or do the deterministic part in Layer A (no per-item LLM at all). + +Always: start on a SCOPED slice (one directory, 20 records, 10 endpoints), prove +the recipe end-to-end, report the token cost, THEN offer to run it at full scale. +Never silently fan out hundreds of children — surface the cost first and let the +user say go. + +## Pitfalls + +- **Writing `delegate_task` inside an `execute_code` script.** It's not in + `SANDBOX_ALLOWED_TOOLS`; the import/stub won't exist. Layer A is deterministic + tools only. Fan out LLM judgment from the parent turn, not from inside a script. +- **Promising background/resumable from `delegate_task`.** It's synchronous and + turn-scoped. Durable = kanban swarm. +- **Trusting `summary` fields for content.** Route structured output to files + (Pattern 2 in delegate-task-output-patterns). +- **Non-atomic claims in the verify recipe.** Unfalsifiable claims survive + refutation by default and pollute the output. Force located, specific claims. +- **Same framing in all "independent" attempts.** They collapse to one answer and + the cross-check is worthless. Vary the angle in each child's context. +- **Fanning out a serial task.** If unit B needs unit A's output, parallelism + produces wrong/empty results. Re-check independence before fanning out. + +## Verification before you call it done + +- Did the deterministic pre-pass actually run, and does the manifest line-count + match the expected unit count? (`wc -l /tmp/wf_/manifest.jsonl`) +- Did every fan-out child write its output file? (`ls /tmp/wf_/out_*.csv`) — + remember stalled children often completed anyway (Pattern 6). +- For the verify recipe: can you point to the refuter counter-evidence for every + DROPPED claim, and confirm every SURFACED claim went through refutation? +- Did you report token cost on the scoped run before offering full scale? From 4f008b6412588a54a0e7569bdb9dd07cc537378b Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:45:18 -0700 Subject: [PATCH 012/610] docs(skills): tighten dynamic-workflow per donovan-yohan review Address all 5 review points against actual delegate_task behavior: - child toolsets are subject to delegate restrictions (leaf strips delegate_task/clarify/memory/send_message/execute_code), not 'full' - durable work has lighter options than kanban (cron one-shot, managed background terminal) for simpler cases - unique per-run /tmp/wf__ dir + freshness/count check so a stale interrupted run isn't read as success - note that one delegate_task batch is capped by delegation.max_concurrent_children; large fan-out needs bounded waves - delegate_task exposes no per-task model/profile field (per-task keys are goal/context/toolsets/role); model/profile-scoped runs go via delegation config, cron, kanban, or separate process --- .../dynamic-workflow/SKILL.md | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/skills/autonomous-ai-agents/dynamic-workflow/SKILL.md b/skills/autonomous-ai-agents/dynamic-workflow/SKILL.md index 0a35fc106b69..2844c00cf5ae 100644 --- a/skills/autonomous-ai-agents/dynamic-workflow/SKILL.md +++ b/skills/autonomous-ai-agents/dynamic-workflow/SKILL.md @@ -47,7 +47,7 @@ the split is enforced by a real capability boundary, not a style preference: |---|---|---| | Use for | DETERMINISTIC fan-out — fetch N URLs, parse N files, run N shell commands, template N outputs | LLM-JUDGMENT fan-out — classify, review, decide, write, refute, audit per item | | The script holds | the loop + branching + intermediate vars (real Python) | n/a — you call it once with a `tasks=[...]` array; each task is its own isolated agent | -| Tools available inside | `web_search, web_extract, read_file, write_file, search_files, terminal, patch` ONLY (the `SANDBOX_ALLOWED_TOOLS` set) | full toolsets per child (configurable) | +| Tools available inside | `web_search, web_extract, read_file, write_file, search_files, terminal, patch` ONLY (the `SANDBOX_ALLOWED_TOOLS` set) | configured child toolsets, subject to delegate restrictions (leaf children are stripped of `delegate_task`, `clarify`, `memory`, `send_message`, `execute_code` — see `DELEGATE_BLOCKED_TOOLS`) | | Can it call `delegate_task`? | **NO.** `delegate_task` is NOT in `SANDBOX_ALLOWED_TOOLS`. Do not write a script that imports it — it will fail. | itself, if `role='orchestrator'` and `max_spawn_depth>=2` | | Concurrency | you control it in Python (`ThreadPoolExecutor`, batches) | `delegation.max_concurrent_children` (default 3; raise in config.yaml) | | Cost shape | cheap — most steps are tool calls, no per-item LLM unless you call `web_search`/aux | one model call tree PER child task — multiplies linearly, can be very expensive | @@ -81,7 +81,11 @@ So a "workflow" in Hermes is one of: path would time out or when the user must be able to walk away. Never promise "background, resumable, hundreds of agents over days" from a plain -`delegate_task` call. That is the kanban path or nothing. +`delegate_task` call. For a durable multi-agent workflow *graph*, the kanban +swarm is the right fit. For simpler durable/out-of-turn cases there are lighter +options too: a `cronjob` one-shot or scheduled job, or a managed +`terminal(background=True, notify_on_complete=True)` process — both survive the +turn without standing up a full task graph. ## Workflow recipe (foreground) @@ -90,19 +94,28 @@ Never promise "background, resumable, hundreds of agents over days" from a plain (else it's serial, not fan-out — see when_not_to_use). 2. **Deterministic pre-pass (Layer A).** In one `execute_code` script, gather the manifest: list the files, extract the candidate sites, fetch the raw sources, - compute anything regex/parse can compute. Write a manifest to - `/tmp/wf_/manifest.jsonl` (one unit per line). This is the "plan in - code." Print a count and stop. + compute anything regex/parse can compute. Write a manifest to a **unique + per-run** directory — `/tmp/wf__/manifest.jsonl` (one unit per + line), never a bare `/tmp/wf_/` that a prior interrupted run could have + left stale outputs in. This is the "plan in code." Print the unit count and + the run dir, and stop. 3. **Size the fan-out** against `delegate-task-output-patterns`: chunk so each child handles ~8-12 mechanical file edits OR ~2000-3000 lines of reading OR - ~50-70KB of corpus. Look at the LARGEST unit, not the average. + ~50-70KB of corpus. Look at the LARGEST unit, not the average. One + `delegate_task(tasks=[...])` call is bounded by + `delegation.max_concurrent_children` (default 3) — it does NOT queue hundreds + of tasks internally. For larger fan-out, issue bounded waves yourself (loop: + one batch, collect, next batch) or have the user raise the config + intentionally. 4. **LLM-judgment fan-out (Layer B).** Issue ONE `delegate_task` with a `tasks=[]` array, one task per chunk. Each task: reads its slice from the manifest, - emits delimiter-separated lines to `/tmp/wf_/out_.csv`, prints a + emits delimiter-separated lines to `/tmp/wf__/out_.csv`, prints a status word, stops. Do NOT depend on the `summary` field for content. -5. **Synthesize on the parent.** Read the out_*.csv files yourself, merge, - present. The cross-cutting "whole picture" step stays on the parent — only the - per-unit work fanned out. +5. **Synthesize on the parent.** Read the out_*.csv files yourself — verify the + file count and freshness (each was written this run) so a stale or missing + output from an interrupted child isn't silently read as success — then merge + and present. The cross-cutting "whole picture" step stays on the parent — only + the per-unit work fanned out. ## The novel mechanic worth building: adversarial convergence @@ -155,9 +168,14 @@ inherent, not a bug. Two real multipliers: - **Each Layer-B child is a full agent tree.** 20 children ≈ 20× the model calls. `delegation.max_concurrent_children` only bounds *concurrency*, not *total*. - **Hermes aux/subagent model defaults to main-model-first.** Children inherit - the parent's (often expensive reasoning) model unless you pin a cheaper one. - For mechanical fan-out, pass a cheaper model per task if the config supports it, - or do the deterministic part in Layer A (no per-item LLM at all). + the parent's (often expensive reasoning) model. `delegate_task` does NOT expose + a per-task `model` or `profile` field — its per-task keys are + `{goal, context, toolsets, role}`. To run the fan-out cheaper you either route + delegation globally via `delegation` config (model/provider applied to all + children), or — for genuinely model/profile-scoped work — use cron, the kanban + swarm, or a separate Hermes process. The cleanest lever for mechanical fan-out + is still Layer A: do the deterministic part in a script with no per-item LLM at + all. Always: start on a SCOPED slice (one directory, 20 records, 10 endpoints), prove the recipe end-to-end, report the token cost, THEN offer to run it at full scale. From 586acf53077e61869ea9ade8ae3c3d838d953e92 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 1 Jun 2026 03:05:41 -0700 Subject: [PATCH 013/610] =?UTF-8?q?feat(curator):=20add=20`hermes=20curato?= =?UTF-8?q?r=20usage`=20=E2=80=94=20all-skills=20usage=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the usage_report()/provenance() data layer added in #36701 as a user-facing CLI command. Unlike `hermes curator status` (scoped to curator-managed agent-created candidates), `usage` lists every skill on disk — bundled built-ins and hub-installed included — with per-skill use/view/patch counts and an agent/bundled/hub provenance tag. Flags: --sort {activity,recent,name}, --provenance {agent,bundled,hub} filter, --json for machine-readable output. --- hermes_cli/curator.py | 79 +++++++++++++++++ tests/hermes_cli/test_curator_usage.py | 112 +++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 tests/hermes_cli/test_curator_usage.py diff --git a/hermes_cli/curator.py b/hermes_cli/curator.py index ce3994480265..c64fd5afef8c 100644 --- a/hermes_cli/curator.py +++ b/hermes_cli/curator.py @@ -488,6 +488,66 @@ def _cmd_list_archived(args) -> int: return 0 +def _cmd_usage(args) -> int: + """Show usage telemetry for ALL skills, with provenance. + + Unlike `status` (curator-scoped to agent-created candidates), this lists + every skill on disk — bundled built-ins and hub-installed included — so you + can see how often each is actually used regardless of curation. + """ + import json as _json + from tools import skill_usage + + rows = skill_usage.usage_report() + + prov_filter = getattr(args, "provenance", None) + if prov_filter: + rows = [r for r in rows if r.get("provenance") == prov_filter] + + sort_key = getattr(args, "sort", "activity") + if sort_key == "name": + rows.sort(key=lambda r: r["name"]) + elif sort_key == "recent": + # Most-recently-active first; never-active sinks to the bottom. + rows.sort(key=lambda r: r.get("last_activity_at") or "", reverse=True) + else: # "activity" (default): most-used first + rows.sort(key=lambda r: r.get("activity_count", 0), reverse=True) + + if getattr(args, "json", False): + print(_json.dumps(rows, indent=2, ensure_ascii=False)) + return 0 + + if not rows: + print("curator: no skills found") + return 0 + + # Provenance tallies for a quick header. + counts = {"agent": 0, "bundled": 0, "hub": 0} + for r in rows: + counts[r.get("provenance", "agent")] = counts.get(r.get("provenance", "agent"), 0) + 1 + print( + f"skills: {len(rows)} total " + f"(agent={counts['agent']} bundled={counts['bundled']} hub={counts['hub']})" + ) + print() + print( + f" {'skill':40s} {'origin':8s} " + f"{'use':>4s} {'view':>4s} {'patch':>5s} {'act':>4s} last_activity" + ) + for r in rows: + last = _fmt_ts(r.get("last_activity_at")) + print( + f" {r['name'][:40]:40s} " + f"{r.get('provenance', 'agent'):8s} " + f"{r.get('use_count', 0):>4d} " + f"{r.get('view_count', 0):>4d} " + f"{r.get('patch_count', 0):>5d} " + f"{r.get('activity_count', 0):>4d} " + f"{last}" + ) + return 0 + + # --------------------------------------------------------------------------- # argparse wiring (called from hermes_cli.main) # --------------------------------------------------------------------------- @@ -504,6 +564,25 @@ def register_cli(parent: argparse.ArgumentParser) -> None: p_status = subs.add_parser("status", help="Show curator status and skill stats") p_status.set_defaults(func=_cmd_status) + p_usage = subs.add_parser( + "usage", + help="Show usage telemetry for ALL skills (built-in, hub, agent) with provenance", + ) + p_usage.add_argument( + "--sort", choices=("activity", "recent", "name"), default="activity", + help="Sort order: activity (most-used first, default), recent " + "(most-recently-active first), or name (alphabetical)", + ) + p_usage.add_argument( + "--provenance", choices=("agent", "bundled", "hub"), default=None, + help="Only show skills of this origin", + ) + p_usage.add_argument( + "--json", action="store_true", + help="Emit the full report as JSON instead of a table", + ) + p_usage.set_defaults(func=_cmd_usage) + p_run = subs.add_parser("run", help="Trigger a curator review now") p_run.add_argument( "--sync", "--synchronous", dest="synchronous", action="store_true", diff --git a/tests/hermes_cli/test_curator_usage.py b/tests/hermes_cli/test_curator_usage.py new file mode 100644 index 000000000000..75e95bfb3e5f --- /dev/null +++ b/tests/hermes_cli/test_curator_usage.py @@ -0,0 +1,112 @@ +"""Tests for `hermes curator usage` — the all-skills usage view. + +Covers: +- Lists every skill regardless of provenance (agent / bundled / hub), unlike + `status` which is scoped to curator-managed candidates. +- --provenance filter, --sort ordering, and --json output. +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + + +def _fake_rows(): + return [ + { + "name": "agent-skill", "provenance": "agent", "state": "active", + "use_count": 2, "view_count": 1, "patch_count": 0, + "activity_count": 3, "last_activity_at": "2026-05-01T10:00:00+00:00", + "created_at": "2026-01-01T00:00:00+00:00", "_persisted": True, + }, + { + "name": "bundled-skill", "provenance": "bundled", "state": "active", + "use_count": 9, "view_count": 4, "patch_count": 0, + "activity_count": 13, "last_activity_at": "2026-05-10T10:00:00+00:00", + "created_at": "2026-01-01T00:00:00+00:00", "_persisted": True, + }, + { + "name": "hub-skill", "provenance": "hub", "state": "active", + "use_count": 0, "view_count": 0, "patch_count": 0, + "activity_count": 0, "last_activity_at": None, + "created_at": "2026-01-01T00:00:00+00:00", "_persisted": False, + }, + ] + + +def test_usage_lists_all_provenances(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "usage_report", _fake_rows) + args = SimpleNamespace(sort="activity", provenance=None, json=False) + assert curator_cli._cmd_usage(args) == 0 + out = capsys.readouterr().out + # Header tally and all three skills present. + assert "agent=1" in out and "bundled=1" in out and "hub=1" in out + assert "agent-skill" in out + assert "bundled-skill" in out + assert "hub-skill" in out + + +def test_usage_sort_activity_orders_most_used_first(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "usage_report", _fake_rows) + args = SimpleNamespace(sort="activity", provenance=None, json=False) + assert curator_cli._cmd_usage(args) == 0 + out = capsys.readouterr().out + # bundled-skill (act=13) must appear before agent-skill (act=3). + assert out.index("bundled-skill") < out.index("agent-skill") + + +def test_usage_provenance_filter(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "usage_report", _fake_rows) + args = SimpleNamespace(sort="activity", provenance="bundled", json=False) + assert curator_cli._cmd_usage(args) == 0 + out = capsys.readouterr().out + assert "bundled-skill" in out + assert "agent-skill" not in out + assert "hub-skill" not in out + + +def test_usage_json_output(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "usage_report", _fake_rows) + args = SimpleNamespace(sort="name", provenance=None, json=True) + assert curator_cli._cmd_usage(args) == 0 + out = capsys.readouterr().out + data = json.loads(out) + assert {r["name"] for r in data} == {"agent-skill", "bundled-skill", "hub-skill"} + assert {r["provenance"] for r in data} == {"agent", "bundled", "hub"} + + +def test_usage_empty(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "usage_report", lambda: []) + args = SimpleNamespace(sort="activity", provenance=None, json=False) + assert curator_cli._cmd_usage(args) == 0 + assert "no skills found" in capsys.readouterr().out + + +def test_usage_command_is_registered(): + """The `usage` subcommand must be wired into the curator argparse tree.""" + import argparse + import hermes_cli.curator as curator_cli + + parser = argparse.ArgumentParser(prog="hermes curator") + curator_cli.register_cli(parser) + args = parser.parse_args(["usage", "--sort", "recent", "--provenance", "hub", "--json"]) + assert args.func is curator_cli._cmd_usage + assert args.sort == "recent" + assert args.provenance == "hub" + assert args.json is True From d4bcd93bb995b398d283a292c01daa1c341324f8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:43:03 -0700 Subject: [PATCH 014/610] docs: browser provider plugin guide + complete the plugin routing map (#59817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New developer-guide/browser-provider-plugin.md: BrowserProvider ABC (session lifecycle, CDP contract, bb_session_id back-compat key, raise/never-raise split between create and close/cleanup), get_setup_schema() hermes-tools integration, discovery, checklist. Closes the one gap in the provider-plugin family — the ABC and ctx.register_browser_provider() existed with zero docs. - Register the page in the Plugins sidebar subcategory. - Extend the routing map on the Plugins landing page (both locales) with the previously missing rows: web-search, browser, secret-source, and dashboard-auth surfaces. --- .../browser-provider-plugin.md | 153 ++++++++++++++++++ website/docs/developer-guide/plugins/index.md | 4 + .../current/developer-guide/plugins/index.md | 4 + website/sidebars.ts | 1 + 4 files changed, 162 insertions(+) create mode 100644 website/docs/developer-guide/browser-provider-plugin.md diff --git a/website/docs/developer-guide/browser-provider-plugin.md b/website/docs/developer-guide/browser-provider-plugin.md new file mode 100644 index 000000000000..abf8a8e80f4b --- /dev/null +++ b/website/docs/developer-guide/browser-provider-plugin.md @@ -0,0 +1,153 @@ +--- +sidebar_position: 13 +title: "Browser Provider Plugins" +description: "How to build a cloud browser backend plugin for Hermes Agent" +--- + +# Building a Browser Provider Plugin + +Browser provider plugins register a **cloud browser backend** that services cloud-mode `browser_*` tool calls (navigate, click, screenshot, …). Built-in providers — Browserbase, Browser Use, and Firecrawl — all ship as plugins under `plugins/browser//`. You can add a new one, or override a bundled one, by dropping a directory next to them. + +:::tip +Browser backends are one of several **backend plugins** Hermes supports. The others (with their own ABCs) are [Web Search Provider Plugins](/developer-guide/web-search-provider-plugin) (which this ABC deliberately mirrors), [Image Generation](/developer-guide/image-gen-provider-plugin), [Video Generation](/developer-guide/video-gen-provider-plugin), [Memory Providers](/developer-guide/memory-provider-plugin), [Context Engines](/developer-guide/context-engine-plugin), [Secret Sources](/developer-guide/secret-source-plugin), and [Model Providers](/developer-guide/model-provider-plugin). General tool/hook/CLI plugins live in [Build a Hermes Plugin](/developer-guide/plugins). +::: + +## How it fits together + +A browser provider does **not** implement browsing. It implements **session lifecycle**: create a remote browser session, hand back a CDP websocket URL, and tear the session down. Hermes' own browser stack (`agent-browser` + `tools/browser_tool.py`) connects to whatever CDP URL you return and drives the page from there — every provider gets the full `browser_*` toolset for free. + +The active provider is selected by `browser.cloud_provider` in `config.yaml`; the dispatcher in `tools/browser_tool.py` is a pure registry lookup with no per-provider conditionals. + +## Discovery + +Hermes scans for browser backends in three places: + +1. **Bundled** — `/plugins/browser//` (auto-loaded with `kind: backend`) +2. **User** — `~/.hermes/plugins/browser//` (opt-in via `plugins.enabled` or `hermes plugins enable `) +3. **Pip** — packages declaring a `hermes_agent.plugins` entry point + +Each plugin's `register(ctx)` calls `ctx.register_browser_provider(...)`, which puts the instance into the registry in `agent/browser_registry.py`. + +## Directory structure + +``` +plugins/browser/my-backend/ +├── __init__.py # register() entry point +├── provider.py # BrowserProvider subclass +└── plugin.yaml # Manifest with kind: backend and provides_browser_providers +``` + +`plugin.yaml`: + +```yaml +name: browser-my-backend +version: 1.0.0 +description: "My cloud browser backend. Requires MY_BACKEND_API_KEY." +author: you +kind: backend +provides_browser_providers: + - my-backend +``` + +`__init__.py`: + +```python +from plugins.browser.my_backend.provider import MyBackendProvider + + +def register(ctx) -> None: + ctx.register_browser_provider(MyBackendProvider()) +``` + +## The BrowserProvider ABC + +Implement `agent.browser_provider.BrowserProvider`. Three lifecycle methods plus identity: + +```python +from agent.browser_provider import BrowserProvider + + +class MyBackendProvider(BrowserProvider): + @property + def name(self) -> str: + return "my-backend" # the browser.cloud_provider config value + + @property + def display_name(self) -> str: + return "My Backend" # shown in `hermes tools` + + def is_available(self) -> bool: + """Cheap check only — env var present, dep importable. + NO network calls: runs at tool-registration time and on every + `hermes tools` paint.""" + return bool(os.environ.get("MY_BACKEND_API_KEY")) + + def create_session(self, task_id: str) -> dict: + """Create a remote browser session; return the session-metadata contract.""" + session = my_api.create_browser(...) + return { + "session_name": f"my-backend-{task_id}", # unique agent-browser session name + "bb_session_id": session.id, # provider session ID (for cleanup) + "cdp_url": session.cdp_ws_url, # CDP websocket URL + "features": {"stealth": True}, # feature flags you enabled + } + + def close_session(self, session_id: str) -> bool: + """Terminate by provider session ID. Log-and-return-False on error — + never raise, so the dispatcher's cleanup loop keeps moving.""" + ... + + def emergency_cleanup(self, session_id: str) -> None: + """Best-effort teardown from atexit/signal handlers. Must not raise.""" + ... +``` + +### The session-metadata contract + +`create_session()` must return at least `session_name`, `bb_session_id`, `cdp_url`, and `features`. Two quirks worth knowing: + +- **`bb_session_id` is a legacy key name** kept verbatim for backward compatibility with `tools/browser_tool.py` — it holds *your* provider's session ID regardless of vendor. Don't rename it. +- `create_session()` **may raise** — `ValueError` for missing credentials, `RuntimeError` for network/API failures. The dispatcher surfaces these to the user. This differs from `close_session`/`emergency_cleanup`, which must never raise. + +An optional `external_call_id` key supports managed-gateway billing. + +### `get_setup_schema()` — the `hermes tools` picker row + +Override this to appear as a first-class option in the Browser Automation picker with API-key prompts and an install hook: + +```python +def get_setup_schema(self) -> dict: + return { + "name": "My Backend", + "badge": "paid", + "tag": "Cloud browser with stealth and proxies", + "env_vars": [ + {"key": "MY_BACKEND_API_KEY", + "prompt": "My Backend API key", + "url": "https://mybackend.example"}, + ], + "post_setup": "agent_browser", # auto-installs the agent-browser npm dep + } +``` + +Per the project standard for tool backends: if a backend can't be selected and configured through `hermes tools`, it isn't done — "set this env var manually" is not an integration. + +## Users configure it + +```yaml +browser: + cloud_provider: my-backend +``` + +## Reference implementations + +The three bundled providers under `plugins/browser/` are the canonical examples, in ascending complexity: `firecrawl` (simplest), `browser_use`, and `browserbase` (stealth/proxy/keep-alive feature flags with graceful fallback when paid features are unavailable). Copy the closest one. + +## Checklist + +- [ ] `name` is lowercase and stable (it's a config value users write) +- [ ] `is_available()` makes zero network calls +- [ ] `create_session()` returns the full metadata contract (`bb_session_id` key name intact) +- [ ] `close_session()` / `emergency_cleanup()` never raise +- [ ] `get_setup_schema()` exposes your env vars so `hermes tools` can configure the backend +- [ ] `plugin.yaml` declares `kind: backend` + `provides_browser_providers` diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index 1fa5d1c5e20a..99fe800b4100 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -21,6 +21,10 @@ Hermes has several distinct pluggable interfaces — some use Python `register_* | A **context-compression engine** | [Context Engine Plugins](/developer-guide/context-engine-plugin) | | An **image-generation backend** | [Image Generation Provider Plugins](/developer-guide/image-gen-provider-plugin) | | A **video-generation backend** | [Video Generation Provider Plugins](/developer-guide/video-gen-provider-plugin) | +| A **web-search / extract backend** | [Web Search Provider Plugins](/developer-guide/web-search-provider-plugin) | +| A **cloud browser backend** (Browserbase-style CDP session provider) | [Browser Provider Plugins](/developer-guide/browser-provider-plugin) | +| A **secret-manager backend** (vault / password manager / OS keystore) | [Secret Source Plugins](/developer-guide/secret-source-plugin) | +| A **dashboard OIDC/auth provider** | [Web Dashboard — custom providers](/user-guide/features/web-dashboard#custom-providers) — `ctx.register_dashboard_auth_provider()` | | A **TTS backend** (any CLI — Piper, VoxCPM, Kokoro, voice cloning, …) | [TTS custom command providers](/user-guide/features/tts#custom-command-providers) — config-driven, no Python needed | | An **STT backend** (custom whisper / ASR CLI) | [Voice Message Transcription](/user-guide/features/tts#voice-message-transcription-stt) — set `HERMES_LOCAL_STT_COMMAND` to a shell template | | **External tools via MCP** (filesystem, GitHub, Linear, any MCP server) | [MCP](/user-guide/features/mcp) — declare `mcp_servers.` in `config.yaml` | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md index 8ffeab09209c..91759dbf554e 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md @@ -20,6 +20,10 @@ Hermes 有多种不同的可插拔接口——有些使用 Python `register_*` A | **上下文压缩引擎** | [上下文引擎插件](/developer-guide/context-engine-plugin) | | **图像生成后端** | [图像生成提供商插件](/developer-guide/image-gen-provider-plugin) | | **视频生成后端** | [视频生成提供商插件](/developer-guide/video-gen-provider-plugin) | +| **网页搜索/提取后端** | [网页搜索提供商插件](/developer-guide/web-search-provider-plugin) | +| **云浏览器后端**(Browserbase 类 CDP 会话提供商) | [浏览器提供商插件](/developer-guide/browser-provider-plugin) | +| **密钥管理器后端**(保险库 / 密码管理器 / 系统钥匙串) | [密钥源插件](/developer-guide/secret-source-plugin) | +| **仪表盘 OIDC/认证提供商** | [Web 仪表盘 — 自定义提供商](/user-guide/features/web-dashboard#custom-providers) — `ctx.register_dashboard_auth_provider()` | | **TTS 后端**(任意 CLI——Piper、VoxCPM、Kokoro、声音克隆等) | [TTS 自定义命令提供商](/user-guide/features/tts#custom-command-providers)——配置驱动,无需 Python | | **STT 后端**(自定义 whisper / ASR CLI) | [语音消息转录](/user-guide/features/tts#voice-message-transcription-stt)——将 `HERMES_LOCAL_STT_COMMAND` 设置为 shell 模板 | | **通过 MCP 接入外部工具**(文件系统、GitHub、Linear、任意 MCP 服务器) | [MCP](/user-guide/features/mcp)——在 `config.yaml` 中声明 `mcp_servers.` | diff --git a/website/sidebars.ts b/website/sidebars.ts index 189406e25994..1843a924eb76 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -749,6 +749,7 @@ const sidebars: SidebarsConfig = { 'developer-guide/image-gen-provider-plugin', 'developer-guide/video-gen-provider-plugin', 'developer-guide/web-search-provider-plugin', + 'developer-guide/browser-provider-plugin', ], }, 'developer-guide/creating-skills', From 78ee0aa36703dad6b25a33303e5fd19c4b3bc0d7 Mon Sep 17 00:00:00 2001 From: edgemass Date: Sat, 13 Jun 2026 17:31:00 +0900 Subject: [PATCH 015/610] [verified] fix: account for codex replay in compression tail budget --- agent/context_compressor.py | 37 +++++++++ tests/agent/test_context_compressor.py | 108 +++++++++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index a51a65d11bbd..45eb25e1ca0b 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -298,6 +298,32 @@ def _content_length_for_budget(raw_content: Any) -> int: return total +def _serialized_length_for_budget(value: Any) -> int: + """Return a stable char-length for non-content replay/metadata fields.""" + if value is None or value == "": + return 0 + if isinstance(value, str): + return len(value) + try: + return len(json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)) + except (TypeError, ValueError): + return len(str(value)) + + +# Provider replay/metadata fields that ride the wire on every request but are +# invisible to ``msg["content"]``/``msg["tool_calls"]`` accounting. Codex +# Responses sessions in particular carry ``codex_reasoning_items`` blobs of +# ``encrypted_content`` that can dominate the serialized session (a measured +# 214-turn session held ~115K tokens / 27% of its payload there — #55572). +_REPLAY_BUDGET_KEYS = ( + "reasoning", + "reasoning_content", + "reasoning_details", + "codex_reasoning_items", + "codex_message_items", +) + + def _estimate_msg_budget_tokens(msg: dict) -> int: """Token estimate for one message in the tail-protection budget walks. @@ -308,12 +334,23 @@ def _estimate_msg_budget_tokens(msg: dict) -> int: 4-tool-call turn measures ~73 vs ~1,090 real tokens), so the protected tail overshot ``tail_token_budget`` and compression became ineffective. See issue #28053. + + Also counts provider replay fields (``codex_reasoning_items`` etc. — + see ``_REPLAY_BUDGET_KEYS``). The preflight "should I compress?" + estimator sees the full message shape, so the tail walk must use the + same size class; otherwise an assistant message with tiny visible + content but large hidden replay blobs is protected as if it were small, + the post-compression session stays near the context limit, and + compaction re-fires continuously (#55572). Accounting-only: replay + fields are never mutated or pruned here. """ content_len = _content_length_for_budget(msg.get("content") or "") tokens = content_len // _CHARS_PER_TOKEN + 10 # +10 for role/key overhead for tc in msg.get("tool_calls") or []: if isinstance(tc, dict): tokens += len(str(tc)) // _CHARS_PER_TOKEN + for key in _REPLAY_BUDGET_KEYS: + tokens += _serialized_length_for_budget(msg.get(key)) // _CHARS_PER_TOKEN return tokens diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 231f8b4077fb..75b22bb8a80d 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -407,6 +407,114 @@ class TestCompress: assert c._effective_protect_first_n() == 0 +class TestTailBudgetCodexReplayFields: + def test_tail_cut_counts_codex_replay_and_reasoning_fields(self): + """Tail protection must budget hidden replay fields sent back to providers. + + Codex Responses messages can have tiny visible content but large + `codex_reasoning_items`, `codex_message_items`, or provider-native + reasoning fields. Preflight compression counts these fields, so the + tail-cut budget must count them too; otherwise compression preserves an + oversized tail and immediately starts the next session near the limit. + """ + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test/model", + protect_first_n=1, + protect_last_n=1, + quiet_mode=True, + ) + + big_replay = "x" * 5_000 + big_hidden_message = { + "role": "assistant", + "content": "ok", + "reasoning": "summary " + big_replay, + "reasoning_content": "scratchpad " + big_replay, + "reasoning_details": [{"text": "details " + big_replay}], + "codex_reasoning_items": [ + {"type": "reasoning", "encrypted_content": "enc_" + big_replay} + ], + "codex_message_items": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "reply " + big_replay}], + } + ], + } + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "initial ask"}, + {"role": "assistant", "content": "first answer"}, + {"role": "user", "content": "older follow-up"}, + big_hidden_message, + ] + messages.extend( + { + "role": "user" if i % 2 == 0 else "assistant", + "content": f"tail visible message {i}", + } + for i in range(14) + ) + + cut_idx = c._find_tail_cut_by_tokens(messages, head_end=1, token_budget=150) + + assert cut_idx == 5 + assert messages[4]["codex_reasoning_items"][0]["encrypted_content"].startswith("enc_") + assert messages[4]["codex_message_items"][0]["content"][0]["text"].startswith("reply ") + + @pytest.mark.parametrize( + ("field_name", "field_value"), + [ + ("reasoning", "x" * 5_000), + ("reasoning_content", "x" * 5_000), + ("reasoning_details", [{"text": "x" * 5_000}]), + ( + "codex_reasoning_items", + [{"type": "reasoning", "encrypted_content": "x" * 5_000}], + ), + ( + "codex_message_items", + [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "x" * 5_000}], + } + ], + ), + ], + ) + def test_tail_cut_counts_each_hidden_replay_field(self, field_name, field_value): + """Each provider replay/reasoning field should affect tail budgeting.""" + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test/model", + protect_first_n=1, + protect_last_n=1, + quiet_mode=True, + ) + + hidden_message = {"role": "assistant", "content": "ok", field_name: field_value} + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "initial ask"}, + {"role": "assistant", "content": "first answer"}, + {"role": "user", "content": "older follow-up"}, + hidden_message, + ] + messages.extend( + { + "role": "user" if i % 2 == 0 else "assistant", + "content": f"tail visible message {i}", + } + for i in range(14) + ) + + assert c._find_tail_cut_by_tokens(messages, head_end=1, token_budget=150) == 5 + + class TestGenerateSummaryNoneContent: """Regression: content=None (from tool-call-only assistant messages) must not crash.""" From 8cc1ca4ce2213b3a3c5f67faa3b572bd5338046c Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:45:32 -0700 Subject: [PATCH 016/610] chore: add bigstar0920 to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index c4ccc2ed9642..1214d44cbcb5 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -284,6 +284,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "bigstar0920@gmail.com": "bigstar0920", "285906080+AIalliAI@users.noreply.github.com": "AIalliAI", "waseemshahwan@users.noreply.github.com": "waseemshahwan", "hellno@users.noreply.github.com": "hellno", From 5e685999afae3bb989e4a9a1cfa2014aba79ac55 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:44:07 -0700 Subject: [PATCH 017/610] fix(ci): make the CI timing report unflakeable (#59818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'CI timing report' job is pure observability — it collects per-job/step durations from the GitHub API after the run and publishes an HTML gantt report + PR-vs-main timing diff. It gates nothing (all-checks-pass does not include it), yet it could redden a PR: the script makes dozens of paginated API calls with the shared repo GITHUB_TOKEN and had zero retry handling, so a single 403 (rate-limit burst when several PRs run CI concurrently) failed the job. Observed twice in a row on PR #59805. - api_get(): retry 403/429/5xx and connection errors with exponential backoff, honoring Retry-After / X-RateLimit-Reset (max 5 attempts, 120s cap). Non-transient statuses (404 etc.) still fail fast. - main(): exhausted retries raise TimingsUnavailable, caught to emit a degraded summary line + placeholder HTML artifact and exit 0 — a metrics collector must never fail the PR's checks. No timings JSON is written on the degraded path so an empty baseline can never be cached. - ci.yml: baseline-save steps on main skip gracefully when no JSON exists. Verified with a mocked urlopen harness: retry-then-success (3 attempts), exhausted-retries -> TimingsUnavailable, 404 fails fast without retry, degraded main() exits 0 with summary + placeholder and no JSON, and the --from-json happy path is unchanged. --- .github/workflows/ci.yml | 11 ++++- scripts/ci/timings_report.py | 88 ++++++++++++++++++++++++++++++++++-- 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 595569a82fa0..ab6012016536 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -221,10 +221,17 @@ jobs: - name: Save baseline cache (main only) if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: cp ci-timings.json ci-timings-baseline.json + run: | + # Degraded runs (API rate-limited) produce no ci-timings.json — + # skip rather than fail, and never cache an empty baseline. + if [ -f ci-timings.json ]; then + cp ci-timings.json ci-timings-baseline.json + else + echo "No timings JSON this run — skipping baseline update" + fi - name: Upload baseline to cache (main only) - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && hashFiles('ci-timings-baseline.json') != '' uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ci-timings-baseline.json diff --git a/scripts/ci/timings_report.py b/scripts/ci/timings_report.py index 20b4b5984539..709472ee1794 100644 --- a/scripts/ci/timings_report.py +++ b/scripts/ci/timings_report.py @@ -22,6 +22,7 @@ import argparse import json import os import sys +import time import urllib.error import urllib.parse import urllib.request @@ -30,6 +31,70 @@ from html import escape API_BASE = "https://api.github.com" +# Retry policy for GitHub API calls. The repo-scoped GITHUB_TOKEN shares a +# rate-limit budget across every concurrent workflow run; when several PRs +# run CI at once, this report job (which makes dozens of paginated calls) +# regularly hits 403 rate-limit responses. Those are transient — retry with +# backoff, honoring Retry-After / X-RateLimit-Reset when present. +_RETRY_STATUSES = {403, 429, 500, 502, 503, 504} +_MAX_ATTEMPTS = 5 +_MAX_RETRY_WAIT_S = 120.0 + + +class TimingsUnavailable(Exception): + """GitHub API data could not be collected (rate limit, outage, ...). + + This is a REPORT job — never a reason to fail the PR's checks. main() + catches this and exits 0 with a degraded summary. + """ + + +def _retry_wait_s(headers, attempt: int) -> float: + """Seconds to wait before the next attempt, honoring server hints.""" + retry_after = (headers.get("Retry-After") or "").strip() if headers else "" + if retry_after.isdigit(): + return min(float(retry_after), _MAX_RETRY_WAIT_S) + reset = (headers.get("X-RateLimit-Reset") or "").strip() if headers else "" + remaining = (headers.get("X-RateLimit-Remaining") or "").strip() if headers else "" + if remaining == "0" and reset.isdigit(): + return min(max(float(reset) - time.time(), 1.0), _MAX_RETRY_WAIT_S) + return min(2.0 ** attempt * 2.0, _MAX_RETRY_WAIT_S) # 4s, 8s, 16s, 32s + + +def _urlopen_with_retry(req: urllib.request.Request): + """urlopen with backoff on rate-limit/transient statuses. + + Returns (parsed_json, link_header). Raises TimingsUnavailable when + attempts are exhausted — callers treat that as "no report this run", + not a job failure. + """ + last_err: Exception | None = None + for attempt in range(1, _MAX_ATTEMPTS + 1): + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()), resp.headers.get("Link", "") + except urllib.error.HTTPError as e: + last_err = e + if e.code not in _RETRY_STATUSES or attempt == _MAX_ATTEMPTS: + break + wait = _retry_wait_s(e.headers, attempt) + print(f"GitHub API {e.code} on {req.full_url} — " + f"retry {attempt}/{_MAX_ATTEMPTS - 1} in {wait:.0f}s", + file=sys.stderr) + time.sleep(wait) + except urllib.error.URLError as e: + last_err = e + if attempt == _MAX_ATTEMPTS: + break + wait = _retry_wait_s(None, attempt) + print(f"GitHub API connection error on {req.full_url} ({e.reason}) — " + f"retry {attempt}/{_MAX_ATTEMPTS - 1} in {wait:.0f}s", + file=sys.stderr) + time.sleep(wait) + raise TimingsUnavailable( + f"GitHub API unavailable after {_MAX_ATTEMPTS} attempts: {last_err}" + ) + # --------------------------------------------------------------------------- # GitHub API helpers @@ -42,6 +107,9 @@ def api_get(path: str, token: str, params: dict | None = None, For list endpoints, pass list_key to extract items from the paginated wrapper response (e.g. list_key='jobs' for {'total_count': N, 'jobs': [...]}). When list_key is omitted, a non-list response is returned as-is (single object). + + Transient failures (403 rate limit, 429, 5xx, connection errors) are + retried with backoff; exhausted retries raise TimingsUnavailable. """ url = f"{API_BASE}{path}" if params: @@ -55,9 +123,7 @@ def api_get(path: str, token: str, params: dict | None = None, "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "ci-timings-report", }) - with urllib.request.urlopen(req) as resp: - data = json.loads(resp.read()) - link_header = resp.headers.get("Link", "") + data, link_header = _urlopen_with_retry(req) if list_key: results.extend(data.get(list_key, [])) @@ -748,8 +814,20 @@ def main(): repo = expect_env("GITHUB_REPOSITORY") run_id = expect_env("GITHUB_RUN_ID") head_sha = expect_env("GITHUB_SHA") - - timings = collect_timings(token, repo, run_id, head_sha) + try: + timings = collect_timings(token, repo, run_id, head_sha) + except TimingsUnavailable as e: + # Observability job: a missing report must never redden the PR. + # Emit a degraded summary + placeholder artifact and exit 0. + msg = f"CI timing data unavailable this run: {e}" + print(msg, file=sys.stderr) + with open(args.summary_out, "a", encoding="utf-8") as f: + f.write(f"\n> ⚠️ {msg}\n") + with open(args.output, "w", encoding="utf-8") as f: + f.write(f"

{escape(msg)}

\n") + # No JSON on purpose: an empty timings file must never be cached + # as the main baseline. + sys.exit(0) # Save JSON with open(args.json_out, "w", encoding="utf-8") as f: From 370a489fb4adb7268f6510439e25e8213bddb624 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:25:29 +0200 Subject: [PATCH 018/610] fix(auxiliary): floor compression timeout so reasoning models don't fall back to marker (#54915) --- agent/auxiliary_client.py | 32 ++- ...est_auxiliary_compression_timeout_floor.py | 189 ++++++++++++++++++ 2 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 tests/agent/test_auxiliary_compression_timeout_floor.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 23d29493a84a..22d410b3ffec 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -5657,6 +5657,17 @@ def _resolve_task_provider_model( _DEFAULT_AUX_TIMEOUT = 30.0 +# Compression summarises large conversation histories; a reasoning auxiliary +# model (e.g. Codex / GPT-5.5) can legitimately take longer than the default +# ``auxiliary.compression.timeout`` (120 s), causing the stream to time out and +# the compressor to fall back to the deterministic context marker (#54915). +# This is a bounded *floor* applied only to config-derived compression timeouts +# — it does not affect other auxiliary tasks and does not override an explicit +# per-call ``timeout=``. A floor is harmless for fast compression models +# (they finish before the deadline) and is a minimum, so a higher config value +# is kept unchanged. +_COMPRESSION_TIMEOUT_FLOOR_SECONDS = 300.0 + def _get_auxiliary_task_config(task: str) -> Dict[str, Any]: """Return the config dict for auxiliary., or {} when unavailable. @@ -5716,6 +5727,23 @@ def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float return default +def _effective_aux_timeout(task: str, timeout: Optional[float]) -> float: + """Resolve the effective timeout for an auxiliary LLM call. + + Uses the caller-provided ``timeout`` when given; otherwise reads + ``auxiliary.{task}.timeout`` from config via :func:`_get_task_timeout`. + For the ``compression`` task only, applies a bounded floor so a reasoning + model summarising a large context is not cut off by the default timeout + (#54915). The floor is intentionally skipped when the caller passes an + explicit ``timeout=`` — explicit per-call deadlines are always honoured — + and it is a minimum (``max``), so a config value already above it is kept. + """ + effective = timeout if timeout is not None else _get_task_timeout(task) + if timeout is None and task == "compression": + effective = max(effective, _COMPRESSION_TIMEOUT_FLOOR_SECONDS) + return effective + + def _get_task_extra_body(task: str) -> Dict[str, Any]: """Read auxiliary..extra_body and return a shallow copy when valid.""" task_config = _get_auxiliary_task_config(task) @@ -6150,7 +6178,7 @@ def call_llm( f"No LLM provider configured for task={task} provider={resolved_provider}. " f"Run: hermes setup") - effective_timeout = timeout if timeout is not None else _get_task_timeout(task) + effective_timeout = _effective_aux_timeout(task, timeout) # Log what we're about to do — makes auxiliary operations visible _base_info = str(getattr(client, "base_url", resolved_base_url) or "") @@ -6739,7 +6767,7 @@ async def async_call_llm( f"No LLM provider configured for task={task} provider={resolved_provider}. " f"Run: hermes setup") - effective_timeout = timeout if timeout is not None else _get_task_timeout(task) + effective_timeout = _effective_aux_timeout(task, timeout) # Pass the client's actual base_url (not just resolved_base_url) so # endpoint-specific temperature overrides can distinguish diff --git a/tests/agent/test_auxiliary_compression_timeout_floor.py b/tests/agent/test_auxiliary_compression_timeout_floor.py new file mode 100644 index 000000000000..64209ce80689 --- /dev/null +++ b/tests/agent/test_auxiliary_compression_timeout_floor.py @@ -0,0 +1,189 @@ +"""Regression tests for the compression-scoped auxiliary timeout floor (#54915). + +Context compression summarises large conversation histories. When the +resolved auxiliary provider is a reasoning model (e.g. Codex / GPT-5.5) the +summary can legitimately exceed the default ``auxiliary.compression.timeout`` +of 120 s, causing the stream to time out and the compressor to fall back to a +deterministic context marker — silently losing the LLM summary. + +The fix layers a *bounded* timeout floor on top of the config-derived +compression timeout, while honouring the four constraints from the issue: + + * Only the ``compression`` task gets the floor (other auxiliary tasks keep + their own timeouts). + * An explicit per-call ``timeout=`` override is **not** floored. + * The floor is a minimum — a config value already above it is unchanged. + * Both the sync (``call_llm``) and async (``async_call_llm``) paths are + covered. + +These tests exercise the real ``call_llm`` / ``async_call_llm`` production +paths with a mocked LLM client and assert the timeout that actually reaches +``client.chat.completions.create``. +""" + +from unittest.mock import patch, MagicMock, AsyncMock + +import pytest + +from agent.auxiliary_client import call_llm, async_call_llm + +# The committed bounded floor for config-derived compression timeouts. +# Behaviour contract (see AGENTS.md "Behavior contracts over snapshots"): +# compression's effective timeout must be at least this when it is +# config-derived. +COMPRESSION_TIMEOUT_FLOOR = 300.0 + +# The default ``auxiliary.compression.timeout`` shipped in the config schema +# (hermes_cli/config.py). Simulated here as the config-derived value. +COMPRESSION_CONFIG_TIMEOUT = 120.0 + + +def _ok_response(): + return {"ok": True} + + +def _client_sync(): + client = MagicMock() + client.base_url = "https://api.openai.com/v1" + client.chat.completions.create.return_value = _ok_response() + return client + + +def _client_async(): + client = MagicMock() + client.base_url = "https://api.openai.com/v1" + client.chat.completions.create = AsyncMock(return_value=_ok_response()) + return client + + +def _patches(client, *, task_timeout): + """Common mocks: provider resolution, cached client, response validation, + and the config-derived task timeout.""" + return ( + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("openai-codex", "gpt-5.5", None, None, None)), + patch("agent.auxiliary_client._get_cached_client", + return_value=(client, "gpt-5.5")), + patch("agent.auxiliary_client._validate_llm_response", + side_effect=lambda resp, _task: resp), + patch("agent.auxiliary_client._get_task_timeout", + return_value=task_timeout), + ) + + +class TestCompressionTimeoutFloorSync: + """Sync ``call_llm`` applies the floor to config-derived compression timeouts.""" + + def test_config_derived_compression_timeout_is_raised_to_floor(self): + """Layer 1: compression with a 120 s config timeout must reach the + client with at least the 300 s floor.""" + client = _client_sync() + p1, p2, p3, p4 = _patches(client, task_timeout=COMPRESSION_CONFIG_TIMEOUT) + with p1, p2, p3, p4: + call_llm( + task="compression", + messages=[{"role": "user", "content": "summarise this"}], + ) + timeout = client.chat.completions.create.call_args.kwargs["timeout"] + assert timeout >= COMPRESSION_TIMEOUT_FLOOR, ( + f"compression timeout {timeout} should be >= floor " + f"{COMPRESSION_TIMEOUT_FLOOR}" + ) + assert timeout > COMPRESSION_CONFIG_TIMEOUT, ( + "the too-low config timeout must not pass through unchanged" + ) + + def test_explicit_per_call_timeout_is_not_floored(self): + """Layer 3: an explicit per-call ``timeout=`` override is honoured + even when it is below the floor.""" + client = _client_sync() + explicit = 60.0 + p1, p2, p3, p4 = _patches(client, task_timeout=COMPRESSION_CONFIG_TIMEOUT) + with p1, p2, p3, p4: + call_llm( + task="compression", + messages=[{"role": "user", "content": "x"}], + timeout=explicit, + ) + timeout = client.chat.completions.create.call_args.kwargs["timeout"] + assert timeout == explicit, ( + f"explicit per-call timeout {explicit} must not be floored, got {timeout}" + ) + + def test_non_compression_task_is_not_floored(self): + """Layer 4: only ``compression`` gets the floor; another auxiliary + task with the same low config timeout must pass it through.""" + client = _client_sync() + low = 30.0 + p1, p2, p3, p4 = _patches(client, task_timeout=low) + with p1, p2, p3, p4: + call_llm( + task="title_generation", + messages=[{"role": "user", "content": "x"}], + ) + timeout = client.chat.completions.create.call_args.kwargs["timeout"] + assert timeout == low, ( + f"non-compression task timeout must stay {low}, got {timeout}" + ) + + def test_higher_config_timeout_is_not_lowered(self): + """Layer 5: the floor is a minimum — a config value already above it + is kept unchanged (``max`` semantics).""" + client = _client_sync() + high = 600.0 + p1, p2, p3, p4 = _patches(client, task_timeout=high) + with p1, p2, p3, p4: + call_llm( + task="compression", + messages=[{"role": "user", "content": "x"}], + ) + timeout = client.chat.completions.create.call_args.kwargs["timeout"] + assert timeout == high, ( + f"config timeout {high} above the floor must be unchanged, got {timeout}" + ) + + +class TestCompressionTimeoutFloorAsync: + """Async ``async_call_llm`` mirrors the sync floor (Layer 2).""" + + @pytest.mark.asyncio + async def test_async_config_derived_compression_timeout_is_raised_to_floor(self): + client = _client_async() + p1, p2, p3, p4 = _patches(client, task_timeout=COMPRESSION_CONFIG_TIMEOUT) + with p1, p2, p3, p4: + await async_call_llm( + task="compression", + messages=[{"role": "user", "content": "summarise this"}], + ) + timeout = client.chat.completions.create.call_args.kwargs["timeout"] + assert timeout >= COMPRESSION_TIMEOUT_FLOOR, ( + f"async compression timeout {timeout} should be >= floor " + f"{COMPRESSION_TIMEOUT_FLOOR}" + ) + + @pytest.mark.asyncio + async def test_async_explicit_per_call_timeout_is_not_floored(self): + client = _client_async() + explicit = 45.0 + p1, p2, p3, p4 = _patches(client, task_timeout=COMPRESSION_CONFIG_TIMEOUT) + with p1, p2, p3, p4: + await async_call_llm( + task="compression", + messages=[{"role": "user", "content": "x"}], + timeout=explicit, + ) + timeout = client.chat.completions.create.call_args.kwargs["timeout"] + assert timeout == explicit + + @pytest.mark.asyncio + async def test_async_non_compression_task_is_not_floored(self): + client = _client_async() + low = 30.0 + p1, p2, p3, p4 = _patches(client, task_timeout=low) + with p1, p2, p3, p4: + await async_call_llm( + task="session_search", + messages=[{"role": "user", "content": "x"}], + ) + timeout = client.chat.completions.create.call_args.kwargs["timeout"] + assert timeout == low From 948993cd62e93a0aa51074c880fb76e7a8e3d673 Mon Sep 17 00:00:00 2001 From: Tanmay Choudhary Date: Sat, 27 Jun 2026 01:46:35 +0200 Subject: [PATCH 019/610] feat(compression): extend Codex 272K compaction autoraise to gpt-5.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ChatGPT Codex OAuth backend caps both gpt-5.4 and gpt-5.5 at a 272K context window, but the autoraise that lifts the compaction trigger to 85% only matched gpt-5.5. On gpt-5.4 the global 50% threshold fired at ~136K — half the usable window — compacting far earlier than necessary. Rename _is_codex_gpt55 -> _is_codex_gpt54_or_gpt55 and match both families. The one-time user notice is now model-aware (shows the actual slug). The config key codex_gpt55_autoraise is kept as-is for backward compatibility. Adds gpt-5.4 coverage to the autoraise tests. --- agent/agent_init.py | 48 +++++++++--------- agent/auxiliary_client.py | 50 +++++++++++------- hermes_cli/config.py | 23 +++++---- tests/agent/test_arcee_trinity_overrides.py | 56 ++++++++++++++------- tests/agent/test_auxiliary_main_first.py | 28 +++++++++++ 5 files changed, 135 insertions(+), 70 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 5d65c4176e36..188060bd7f24 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -68,18 +68,19 @@ def _ra(): return run_agent -def _build_codex_gpt55_autoraise_notice(autoraise: Dict[str, float]) -> str: - """Build the one-time notice shown when Codex gpt-5.5 raises compaction. +def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, float]) -> str: + """Build the one-time notice shown when Codex gpt-5.x raises compaction. - ``autoraise`` is ``{"from": , "to": }``. The same - text is printed inline for CLI users and replayed via ``status_callback`` - for gateway users, so it must be self-contained and include the exact - opt-back-out command. + ``autoraise`` is ``{"model": , "from": , "to": }``. + The same text is printed inline for CLI users and replayed via + ``status_callback`` for gateway users, so it must be self-contained and + include the exact opt-back-out command. """ + model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1] from_pct = int(round(autoraise["from"] * 100)) to_pct = int(round(autoraise["to"] * 100)) return ( - f"ℹ Codex gpt-5.5 caps context at 272K, so auto-compaction was raised " + f"ℹ Codex {model} caps context at 272K, so auto-compaction was raised " f"to {to_pct}% (from {from_pct}%) to use more of the window before " f"summarizing.\n" f" Opt back out: hermes config set compression.codex_gpt55_autoraise false" @@ -1409,14 +1410,14 @@ def init_agent( if not isinstance(_compression_cfg, dict): _compression_cfg = {} compression_threshold = float(_compression_cfg.get("threshold", 0.50)) - # Per-model/route compaction-threshold override. Codex gpt-5.5 raises to - # 85% (the Codex backend caps the window at 272K, so the default 50% would - # compact at ~136K — half the usable context). Gated by an opt-out config - # flag so the user can fall back to the global threshold; when the override - # fires we stash a one-time notification (replayed on the first turn) that - # tells the user what changed and how to revert. The notice has its own - # display gate so users can keep the threshold autoraise without getting - # the banner on gateway turns. + # Per-model/route compaction-threshold override. Codex gpt-5.4 / gpt-5.5 + # raise to 85% (the Codex backend caps both families at 272K, so the + # default 50% would compact at ~136K — half the usable context). Gated by + # an opt-out config flag so the user can fall back to the global threshold; + # when the override fires we stash a one-time notification (replayed on the + # first turn) that tells the user what changed and how to revert. The + # notice has its own display gate so users can keep the threshold + # autoraise without getting the banner on gateway turns. _codex_gpt55_autoraise = str( _compression_cfg.get("codex_gpt55_autoraise", True) ).lower() in {"true", "1", "yes"} @@ -1427,7 +1428,7 @@ def init_agent( try: from agent.auxiliary_client import ( _compression_threshold_for_model as _cthresh_fn, - _is_codex_gpt55 as _is_codex_gpt55_fn, + _is_codex_gpt54_or_gpt55 as _is_codex_gpt54_or_gpt55_fn, ) _model_cthresh = _cthresh_fn( agent.model, @@ -1437,15 +1438,16 @@ def init_agent( if _model_cthresh is not None: _prev_threshold = compression_threshold compression_threshold = _model_cthresh - # Notify only for the Codex gpt-5.5 autoraise (the Arcee Trinity - # override is a long-standing silent default). Skip the notice when - # the user's global threshold already meets/exceeds the raised - # value, since nothing actually changed for them. + # Notify only for the Codex gpt-5.4 / gpt-5.5 autoraise (the Arcee + # Trinity override is a long-standing silent default). Skip the + # notice when the user's global threshold already meets/exceeds the + # raised value, since nothing actually changed for them. if ( - _is_codex_gpt55_fn(agent.model, agent.provider) + _is_codex_gpt54_or_gpt55_fn(agent.model, agent.provider) and _model_cthresh > _prev_threshold + 1e-9 ): agent._compression_threshold_autoraised = { + "model": agent.model, "from": _prev_threshold, "to": _model_cthresh, } @@ -1910,7 +1912,7 @@ def init_agent( # turn 1 (set below, after the warning slot is initialized). _autoraise = getattr(agent, "_compression_threshold_autoraised", None) if _autoraise and compression_enabled and _codex_gpt55_autoraise_notice: - print(_build_codex_gpt55_autoraise_notice(_autoraise)) + print(_build_codex_gpt5_autoraise_notice(_autoraise)) # Check immediately so CLI users see the warning at startup. # Gateway status_callback is not yet wired, so any warning is stored @@ -1921,7 +1923,7 @@ def init_agent( # through status_callback on the first turn (Telegram/Discord/Slack/etc.). _autoraise = getattr(agent, "_compression_threshold_autoraised", None) if _autoraise and compression_enabled and _codex_gpt55_autoraise_notice: - agent._compression_warning = _build_codex_gpt55_autoraise_notice(_autoraise) + agent._compression_warning = _build_codex_gpt5_autoraise_notice(_autoraise) # Lazy feasibility check: deferred to the first turn that approaches the # compression threshold. Running it eagerly here costs ~400ms cold (network # probe of the auxiliary provider chain + /models lookup) on every agent diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 22d410b3ffec..be02c2aedd10 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -314,33 +314,40 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool: return bare == "trinity-large-thinking" -# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.5. +# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.4/5.5. # The raw OpenAI API and OpenRouter expose 1.05M for the same slug, but the # Codex backend hard-caps at 272K (verified live: a ~330K-token request to # chatgpt.com/backend-api/codex/responses is rejected with # ``context_length_exceeded`` while ~250K succeeds). With a 272K ceiling the # default 50% compaction trigger fires at ~136K — wasteful, since the model # can hold far more raw context before summarization actually buys anything. -# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.5 -# sessions use the window they actually have. -_CODEX_GPT55_COMPACTION_THRESHOLD = 0.85 +# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.4/ +# gpt-5.5 sessions use the window they actually have. +_CODEX_GPT54_GPT55_COMPACTION_THRESHOLD = 0.85 -def _is_codex_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool: - """True for gpt-5.5 accessed through the ChatGPT Codex OAuth backend. +def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool: + """True for gpt-5.4 / gpt-5.5 on the ChatGPT Codex OAuth backend. Matches only the Codex OAuth route (provider ``openai-codex``), not the direct OpenAI API, OpenRouter, or GitHub Copilot paths — those expose a larger context window for the same slug and must keep the user's default - compaction threshold. ``gpt-5.5-pro`` and dated snapshots - (``gpt-5.5-2026-04-23``) are matched via prefix so the override tracks the - family without re-listing every variant. + compaction threshold. ``gpt-5.4-pro`` / ``gpt-5.5-pro`` and dated snapshots + are matched via prefix so the override tracks both 272K-capped families + without re-listing every variant. """ prov = (provider or "").strip().lower() if prov != "openai-codex": return False bare = (model or "").strip().lower().rsplit("/", 1)[-1] - return bare == "gpt-5.5" or bare.startswith("gpt-5.5-") or bare.startswith("gpt-5.5.") + return ( + bare == "gpt-5.4" + or bare.startswith("gpt-5.4-") + or bare.startswith("gpt-5.4.") + or bare == "gpt-5.5" + or bare.startswith("gpt-5.5-") + or bare.startswith("gpt-5.5.") + ) def _fixed_temperature_for_model( @@ -379,18 +386,19 @@ def _compression_threshold_for_model( Per-model/route overrides: - Arcee Trinity Large Thinking → 0.75 (preserve reasoning context). - - gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps the window - at 272K and the default 50% trigger would compact at ~136K. Gated by - ``allow_codex_gpt55_autoraise`` so the user can opt back down to the - global default (the caller passes the config flag through here). + - gpt-5.4 / gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps + both families at 272K and the default 50% trigger would compact at + ~136K. Gated by ``allow_codex_gpt55_autoraise`` (historical config-key + name kept for backward compatibility) so the user can opt back down to + the global default (the caller passes the config flag through here). Returns a float in (0, 1] to override the global ``compression.threshold`` config value, or ``None`` to leave the user's config value unchanged. """ if _is_arcee_trinity_thinking(model): return 0.75 - if allow_codex_gpt55_autoraise and _is_codex_gpt55(model, provider): - return _CODEX_GPT55_COMPACTION_THRESHOLD + if allow_codex_gpt55_autoraise and _is_codex_gpt54_or_gpt55(model, provider): + return _CODEX_GPT54_GPT55_COMPACTION_THRESHOLD return None # Default auxiliary models for direct API-key providers (cheap/fast for side tasks) @@ -4151,7 +4159,15 @@ def resolve_provider_client( # main_model also empty), the branches still hit their own # missing-credentials returns and ``_resolve_auto`` falls through to # the Step-2 chain as before. - if not model: + # + # Prefer explicit caller model, then provider-scoped aux model, then main model. + # Do NOT pre-fill a blank ``auto`` request from the config/main default here. + # ``auto`` has its own main-runtime resolver below; pre-filling first can pair + # a stale configured model with a live fallback provider (e.g. Claude model + # sent to Codex after the main lane fell back to gpt-5.5). Let _resolve_auto() + # return the actual current runtime model when the caller did not explicitly + # request one. (# compression-current-model) + if not model and provider != "auto": model = _get_aux_model_for_provider(provider) or _read_main_model() or model def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 3801ebd4d94e..81d66e99eba0 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1362,17 +1362,18 @@ DEFAULT_CONFIG = { # Default False matches historical behavior; set to # True if you'd rather pause than silently lose # context turns when your aux model is flaky. - "codex_gpt55_autoraise": True, # When True, gpt-5.5 on the ChatGPT Codex OAuth - # route raises its compaction trigger to 85% (vs the - # global `threshold` above). Codex hard-caps gpt-5.5 - # at a 272K window, so the default 50% would compact - # at ~136K and waste half the usable context. Set to - # False to opt back down to the global threshold - # (e.g. 0.50) for Codex gpt-5.5 sessions. Only this - # exact route is affected — gpt-5.5 on OpenAI's - # direct API, OpenRouter, and Copilot keep the - # global threshold regardless. - "codex_gpt55_autoraise_notice": True, # Display the one-time Codex gpt-5.5 + "codex_gpt55_autoraise": True, # Historical key name kept for compatibility. + # When True, gpt-5.4 / gpt-5.5 on the ChatGPT Codex + # OAuth route raise their compaction trigger to 85% + # (vs the global `threshold` above). Codex hard-caps + # both families at a 272K window, so the default 50% + # would compact at ~136K and waste half the usable + # context. Set to False to opt back down to the global + # threshold (e.g. 0.50) for those Codex sessions. + # Only this exact route is affected — gpt-5.4 / 5.5 + # on OpenAI's direct API, OpenRouter, and Copilot keep + # the global threshold regardless. + "codex_gpt55_autoraise_notice": True, # Display the one-time Codex gpt-5.4/5.5 # autoraise banner. Set False to keep the # 85% threshold autoraise but suppress the # user-facing notice in CLI/gateway output. diff --git a/tests/agent/test_arcee_trinity_overrides.py b/tests/agent/test_arcee_trinity_overrides.py index 91a5fa743bb5..965e9150a6c0 100644 --- a/tests/agent/test_arcee_trinity_overrides.py +++ b/tests/agent/test_arcee_trinity_overrides.py @@ -17,7 +17,7 @@ from agent.auxiliary_client import ( _compression_threshold_for_model, _fixed_temperature_for_model, _is_arcee_trinity_thinking, - _is_codex_gpt55, + _is_codex_gpt54_or_gpt55, ) @@ -78,14 +78,14 @@ def test_compression_threshold_default_none_for_other_models() -> None: # --------------------------------------------------------------------------- -# Codex gpt-5.5 compaction-threshold autoraise +# Codex gpt-5.4 / gpt-5.5 compaction-threshold autoraise # -# ChatGPT's Codex OAuth backend caps gpt-5.5 at a 272K window (verified live: -# ~330K-token request rejected with context_length_exceeded, ~250K accepted). -# The default 50% compaction trigger would fire at ~136K — half the usable -# window — so this route raises the trigger to 85%. Only the Codex OAuth route -# is affected; the same slug on OpenAI direct / OpenRouter / Copilot exposes a -# larger window and keeps the user's global threshold. +# ChatGPT's Codex OAuth backend caps both families at a 272K window (verified +# live via the Codex /models resolver and per-slug fallback table). The default +# 50% compaction trigger would fire at ~136K — half the usable window — so this +# route raises the trigger to 85%. Only the Codex OAuth route is affected; the +# same slugs on OpenAI direct / OpenRouter / Copilot expose a larger window and +# keep the user's global threshold. # --------------------------------------------------------------------------- @@ -99,32 +99,40 @@ def test_compression_threshold_default_none_for_other_models() -> None: "openai/gpt-5.5", # aggregator-prefixed (still on the codex route) "GPT-5.5", # case-insensitive " gpt-5.5 ", # whitespace tolerant + "gpt-5.4", # base 5.4 (272K-capped) + "gpt-5.4-pro", # pro 5.4 variant (272K-capped) + "gpt-5.4-2026-01-01", # dated 5.4 snapshot + "openai/gpt-5.4", # aggregator-prefixed 5.4 ], ) -def test_is_codex_gpt55_matches_on_codex_provider(model: str) -> None: - assert _is_codex_gpt55(model, "openai-codex") is True +def test_is_codex_gpt54_or_gpt55_matches_on_codex_provider(model: str) -> None: + assert _is_codex_gpt54_or_gpt55(model, "openai-codex") is True @pytest.mark.parametrize( "provider", ["openrouter", "openai", "copilot", "openai-api", "", None], ) -def test_is_codex_gpt55_rejects_non_codex_providers(provider) -> None: - # gpt-5.5 on any non-Codex route keeps the larger window — no override. - assert _is_codex_gpt55("gpt-5.5", provider) is False +def test_is_codex_gpt54_or_gpt55_rejects_non_codex_providers(provider) -> None: + # gpt-5.4 / gpt-5.5 on any non-Codex route keep the larger window. + assert _is_codex_gpt54_or_gpt55("gpt-5.5", provider) is False + assert _is_codex_gpt54_or_gpt55("gpt-5.4", provider) is False @pytest.mark.parametrize( "model", - ["gpt-5.4", "gpt-5", "gpt-5.55", "gpt-5.50", "", None], + ["gpt-5", "gpt-5.55", "gpt-5.50", "gpt-5.45", "gpt-5.40", "", None], ) -def test_is_codex_gpt55_rejects_non_55_models(model) -> None: - # gpt-5.55 / gpt-5.50 are different families and must NOT match — the - # "gpt-5.5-" / "gpt-5.5." prefix guards require a separator after "5.5". - assert _is_codex_gpt55(model, "openai-codex") is False +def test_is_codex_gpt54_or_gpt55_rejects_non_54_55_models(model) -> None: + # Close numeric neighbours must NOT match — the prefix guards require a + # separator after "5.4" / "5.5" so e.g. gpt-5.45 and gpt-5.55 stay out. + assert _is_codex_gpt54_or_gpt55(model, "openai-codex") is False def test_compression_threshold_for_codex_gpt55() -> None: + assert _compression_threshold_for_model("gpt-5.4", "openai-codex") == 0.85 + assert _compression_threshold_for_model("gpt-5.4-pro", "openai-codex") == 0.85 + assert _compression_threshold_for_model("openai/gpt-5.4", "openai-codex") == 0.85 assert _compression_threshold_for_model("gpt-5.5", "openai-codex") == 0.85 assert _compression_threshold_for_model("gpt-5.5-pro", "openai-codex") == 0.85 assert _compression_threshold_for_model("openai/gpt-5.5", "openai-codex") == 0.85 @@ -132,14 +140,24 @@ def test_compression_threshold_for_codex_gpt55() -> None: def test_compression_threshold_codex_gpt55_other_routes_unaffected() -> None: # Same slug, different route → no override (keep the user's config value). + assert _compression_threshold_for_model("gpt-5.4", "openrouter") is None + assert _compression_threshold_for_model("gpt-5.4", "openai") is None + assert _compression_threshold_for_model("gpt-5.4", "copilot") is None assert _compression_threshold_for_model("gpt-5.5", "openrouter") is None assert _compression_threshold_for_model("gpt-5.5", "openai") is None assert _compression_threshold_for_model("gpt-5.5", "copilot") is None + assert _compression_threshold_for_model("openai/gpt-5.4") is None # no provider assert _compression_threshold_for_model("openai/gpt-5.5") is None # no provider def test_compression_threshold_codex_gpt55_opt_out() -> None: - # allow_codex_gpt55_autoraise=False reverts to the global default (None). + # Historical flag name still governs both Codex families. + assert ( + _compression_threshold_for_model( + "gpt-5.4", "openai-codex", allow_codex_gpt55_autoraise=False + ) + is None + ) assert ( _compression_threshold_for_model( "gpt-5.5", "openai-codex", allow_codex_gpt55_autoraise=False diff --git a/tests/agent/test_auxiliary_main_first.py b/tests/agent/test_auxiliary_main_first.py index 0b8b0a04462a..935bb04371d8 100644 --- a/tests/agent/test_auxiliary_main_first.py +++ b/tests/agent/test_auxiliary_main_first.py @@ -279,6 +279,34 @@ class TestResolveAutoMainFirst: assert mock_resolve.call_args.args[0] == "anthropic" assert mock_resolve.call_args.args[1] == "runtime-model" + def test_resolve_provider_auto_returns_runtime_model_not_stale_config_default(self): + """Blank auto aux requests must not pair a stale config model with live fallback provider.""" + runtime_client = MagicMock() + with patch( + "agent.auxiliary_client._read_main_model", + return_value="claude-opus-4-8", + ) as mock_read_main_model, patch( + "agent.auxiliary_client._resolve_auto", + return_value=(runtime_client, "gpt-5.5"), + ) as mock_resolve_auto: + from agent.auxiliary_client import resolve_provider_client + + client, model = resolve_provider_client( + "auto", + main_runtime={ + "provider": "openai-codex", + "model": "gpt-5.5", + "base_url": "", + "api_key": "", + "api_mode": "codex_responses", + }, + ) + + assert client is runtime_client + assert model == "gpt-5.5" + mock_read_main_model.assert_not_called() + mock_resolve_auto.assert_called_once() + def test_runtime_base_url_passed_for_named_api_key_provider(self): """Named API-key providers inherit the live session endpoint for aux work.""" token_plan_url = "https://token-plan-sgp.xiaomimimo.com/v1" From 0b6df665a929bd7389bd5b604655048005cf1183 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:13:12 +0200 Subject: [PATCH 020/610] fix(compression): autoraise gpt-5.3-codex-spark threshold to 70% (#48621) gpt-5.3-codex-spark has a native 128K context window but the default 50% compaction trigger fires at ~64K, wasting half the usable window before the session has accumulated enough turns to summarize meaningfully. This raises the trigger to 70% (~90K) on the Codex OAuth route only, leaving ~38K headroom for the summary and continued conversation before the 128K hard limit. The override is not gated by allow_codex_gpt55_autoraise because 128K is the model's native window (unlike gpt-5.5's artificial 272K Codex cap). Non-Codex routes are unaffected. Also adds a boundary regression test verifying the short-session scenario from the issue always yields a non-empty compressible window (no silent context wipe). --- agent/auxiliary_client.py | 29 ++++++++ tests/agent/test_arcee_trinity_overrides.py | 73 +++++++++++++++++++ .../test_infinite_compaction_loop.py | 55 ++++++++++++++ 3 files changed, 157 insertions(+) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index be02c2aedd10..1ed366d24390 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -325,6 +325,15 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool: # gpt-5.5 sessions use the window they actually have. _CODEX_GPT54_GPT55_COMPACTION_THRESHOLD = 0.85 +# gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a +# native 128K context window. The default 50% compaction trigger fires at +# ~64K — wasting half the usable window, often before the session has enough +# turns to summarize meaningfully. We raise the trigger to 70% (~90K) so +# spark sessions use more of the window before summarization, while still +# leaving ~38K headroom for the summary and continued conversation before +# the 128K hard limit. +_CODEX_SPARK_COMPACTION_THRESHOLD = 0.70 + def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool: """True for gpt-5.4 / gpt-5.5 on the ChatGPT Codex OAuth backend. @@ -350,6 +359,21 @@ def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = Non ) +def _is_codex_spark(model: Optional[str], provider: Optional[str] = None) -> bool: + """True for ``gpt-5.3-codex-spark`` on the ChatGPT Codex OAuth backend. + + The model is Codex-OAuth-only (ChatGPT Pro entitlement) with a native + 128K context window. Only the Codex OAuth route (provider + ``openai-codex``) is matched — the slug is not available on other + routes. + """ + prov = (provider or "").strip().lower() + if prov != "openai-codex": + return False + bare = (model or "").strip().lower().rsplit("/", 1)[-1] + return bare == "gpt-5.3-codex-spark" + + def _fixed_temperature_for_model( model: Optional[str], base_url: Optional[str] = None, @@ -391,6 +415,9 @@ def _compression_threshold_for_model( ~136K. Gated by ``allow_codex_gpt55_autoraise`` (historical config-key name kept for backward compatibility) so the user can opt back down to the global default (the caller passes the config flag through here). + - gpt-5.3-codex-spark on the Codex OAuth route → 0.70, because the model + has a native 128K window and the default 50% trigger would compact at + ~64K — wasting half the usable context. Gated by the same flag. Returns a float in (0, 1] to override the global ``compression.threshold`` config value, or ``None`` to leave the user's config value unchanged. @@ -399,6 +426,8 @@ def _compression_threshold_for_model( return 0.75 if allow_codex_gpt55_autoraise and _is_codex_gpt54_or_gpt55(model, provider): return _CODEX_GPT54_GPT55_COMPACTION_THRESHOLD + if allow_codex_gpt55_autoraise and _is_codex_spark(model, provider): + return _CODEX_SPARK_COMPACTION_THRESHOLD return None # Default auxiliary models for direct API-key providers (cheap/fast for side tasks) diff --git a/tests/agent/test_arcee_trinity_overrides.py b/tests/agent/test_arcee_trinity_overrides.py index 965e9150a6c0..edb1f7250b51 100644 --- a/tests/agent/test_arcee_trinity_overrides.py +++ b/tests/agent/test_arcee_trinity_overrides.py @@ -18,6 +18,7 @@ from agent.auxiliary_client import ( _fixed_temperature_for_model, _is_arcee_trinity_thinking, _is_codex_gpt54_or_gpt55, + _is_codex_spark, ) @@ -175,3 +176,75 @@ def test_compression_threshold_opt_out_does_not_disable_trinity() -> None: ) == 0.75 ) + + +# --------------------------------------------------------------------------- +# Codex gpt-5.3-codex-spark compaction-threshold autoraise +# +# gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a +# native 128K context window. The default 50% compaction trigger would fire +# at ~64K — wasting half the usable window, often before the session has +# accumulated enough turns to summarize meaningfully. This route raises the +# trigger to 70% (~90K) to preserve more raw context while leaving ~38K +# headroom before the 128K hard limit. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "model", + [ + "gpt-5.3-codex-spark", + "openai/gpt-5.3-codex-spark", # aggregator-prefixed (still on the codex route) + "GPT-5.3-CODEX-SPARK", # case-insensitive + " gpt-5.3-codex-spark ", # whitespace tolerant + ], +) +def test_is_codex_spark_matches_on_codex_provider(model: str) -> None: + assert _is_codex_spark(model, "openai-codex") is True + + +@pytest.mark.parametrize( + "provider", + ["openrouter", "openai", "copilot", "openai-api", "", None], +) +def test_is_codex_spark_rejects_non_codex_providers(provider) -> None: + # spark on any non-Codex route is not a real slug — no override. + assert _is_codex_spark("gpt-5.3-codex-spark", provider) is False + + +@pytest.mark.parametrize( + "model", + [ + "gpt-5.5", # different family + "gpt-5.3-codex", # sibling, not spark + "gpt-5.3", # bare 5.3, not spark + "gpt-5.3-codex-spark-mini", # hypothetical variant — not matched yet + "", None, + ], +) +def test_is_codex_spark_rejects_non_spark_models(model) -> None: + assert _is_codex_spark(model, "openai-codex") is False + + +def test_compression_threshold_for_codex_spark() -> None: + assert _compression_threshold_for_model("gpt-5.3-codex-spark", "openai-codex") == 0.70 + assert _compression_threshold_for_model("openai/gpt-5.3-codex-spark", "openai-codex") == 0.70 + + +def test_compression_threshold_codex_spark_other_routes_unaffected() -> None: + # Same slug, different route → no override (keep the user's config value). + assert _compression_threshold_for_model("gpt-5.3-codex-spark", "openrouter") is None + assert _compression_threshold_for_model("gpt-5.3-codex-spark", "openai") is None + assert _compression_threshold_for_model("gpt-5.3-codex-spark") is None # no provider + + +def test_compression_threshold_codex_spark_not_gated_by_gpt55_optout() -> None: + # The spark autoraise is independent of the gpt-5.5 opt-out flag — 128K is + # the model's native window, so 70% is unambiguously correct regardless of + # whether the user opted out of the (artificial-cap) gpt-5.5 autoraise. + assert ( + _compression_threshold_for_model( + "gpt-5.3-codex-spark", "openai-codex", allow_codex_gpt55_autoraise=False + ) + == 0.70 + ) diff --git a/tests/run_agent/test_infinite_compaction_loop.py b/tests/run_agent/test_infinite_compaction_loop.py index fc26a2f4199e..31a7a12ba16e 100644 --- a/tests/run_agent/test_infinite_compaction_loop.py +++ b/tests/run_agent/test_infinite_compaction_loop.py @@ -283,3 +283,58 @@ class TestCooldownGuard: comp.last_prompt_tokens = 65_000 assert comp._summary_failure_cooldown_until == 0.0 assert comp.should_compress(65_000) + + +# --------------------------------------------------------------------------- +# Test: #48621 — gpt-5.3-codex-spark short-session boundary +# +# Issue #48621 Bug 2 claims that a short high-token session (15-20 messages, +# ~90k tokens on a 128k model with protect_last_n=20) hits +# compress_start >= compress_end, causing a silent context wipe. The +# raw-budget fallback added in the #40803 fix already mitigates this: the +# boundary logic always exposes a minimal compressible window. This test +# locks that behavior in for the exact #48621 parameters. +# --------------------------------------------------------------------------- + +class TestCodexSparkShortSessionBoundary: + """Verify that gpt-5.3-codex-spark's short-session scenario always yields + a non-empty compressible window (no silent wipe).""" + + def test_short_high_token_session_has_compressible_window(self): + """16 messages with large tool outputs on a 128k model must leave + a compressible middle (compress_start < compress_end).""" + comp = _make_compressor( + model="gpt-5.3-codex-spark", + threshold_percent=0.70, + protect_first_n=3, + protect_last_n=20, + config_context_length=128000, + ) + # Build system + 3 head pairs + 3 tool groups (large outputs) + tail pair + big_tool = "x" * 20000 # ~5k tokens each + messages = [{"role": "system", "content": "You are a helpful agent."}] + for i in range(3): + messages.append({"role": "user", "content": f"Question {i}"}) + messages.append({"role": "assistant", "content": f"Answer {i}"}) + for i in range(3): + messages.append({"role": "user", "content": f"Run command {i}"}) + messages.append({ + "role": "assistant", "content": "", + "tool_calls": [{ + "id": f"tc{i}", "type": "function", + "function": {"name": "terminal", "arguments": "{}"}, + }], + }) + messages.append({"role": "tool", "tool_call_id": f"tc{i}", "content": big_tool}) + messages.append({"role": "user", "content": "Final question"}) + messages.append({"role": "assistant", "content": "Final answer"}) + + head = comp._protect_head_size(messages) + compress_start = comp._align_boundary_forward(messages, head) + compress_end = comp._find_tail_cut_by_tokens(messages, compress_start) + + assert compress_start < compress_end, ( + f"No compressible window: start={compress_start}, end={compress_end}. " + f"This would cause the silent context wipe described in #48621." + ) + assert comp.has_content_to_compress(messages) is True From bdca94e7491c52c53672a82a1fe28ecd23336211 Mon Sep 17 00:00:00 2001 From: sasquatch9818 <290858493+sasquatch9818@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:29:27 +0300 Subject: [PATCH 021/610] fix(compression): keep Codex gpt-5.5 autoraise from lowering a higher threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Codex gpt-5.5 compaction autoraise (#40957) overrode the effective threshold unconditionally. If a user had set compression.threshold above 0.85, agent_init dropped them down to 0.85. That wastes usable window and contradicts the feature's whole point: use more of the context, not less. It happened silently too, since the one-time notice is suppressed when the override doesn't raise. The override is an autoraise. It must only raise. Pulled the apply logic into a small pure helper that clamps the Codex case to never lower a higher-or-equal user threshold, and emits the notice only when it actually fires. Other overrides (Arcee Trinity) keep their existing unconditional behavior. Fixes the Codex gpt-5.5 compaction autoraise lowering a user's higher configured threshold. A user on the Codex OAuth route with compression.threshold > 0.85 was silently clamped to 0.85, compacting earlier than they asked and using less of the 272K window the feature was meant to unlock. The autoraise now only ever raises. N/A - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [ ] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) - `agent/agent_init.py`: added `_resolve_compression_threshold()`, a pure helper that combines the global threshold with a per-model override. The Codex gpt-5.5 autoraise never lowers a higher-or-equal user threshold; the notice is returned only when it actually raises. Rewired `init_agent` to call it, replacing the unconditional `compression_threshold = _model_cthresh`. - `tests/agent/test_arcee_trinity_overrides.py`: added 5 cases for the helper — raise from default, never-lower regression, equal-is-noop, no-override passthrough, and non-codex (Trinity) unconditional apply. 1. Set `compression.threshold: 0.90` and run gpt-5.5 on provider `openai-codex`. 2. Before: effective threshold drops to 0.85, no notice. After: stays 0.90. 3. Run `scripts/run_tests.sh tests/agent/test_arcee_trinity_overrides.py`. Stash `agent/agent_init.py` and the new cases fail; restore and they pass. - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 (Darwin 25.5) - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A --- agent/agent_init.py | 73 ++++++++++++++++----- agent/auxiliary_client.py | 6 +- tests/agent/test_arcee_trinity_overrides.py | 66 +++++++++++++++++++ 3 files changed, 126 insertions(+), 19 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 188060bd7f24..107e78cd511e 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -77,16 +77,54 @@ def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, float]) -> str: include the exact opt-back-out command. """ model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1] + # gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5 family is + # capped at 272K by the Codex OAuth backend. + cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K" from_pct = int(round(autoraise["from"] * 100)) to_pct = int(round(autoraise["to"] * 100)) return ( - f"ℹ Codex {model} caps context at 272K, so auto-compaction was raised " + f"ℹ Codex {model} caps context at {cap}, so auto-compaction was raised " f"to {to_pct}% (from {from_pct}%) to use more of the window before " f"summarizing.\n" f" Opt back out: hermes config set compression.codex_gpt55_autoraise false" ) +def _resolve_compression_threshold( + global_threshold: float, + model_cthresh: Optional[float], + *, + model: Optional[str] = None, + is_codex_autoraise: bool, +) -> tuple[float, Optional[Dict[str, Any]]]: + """Combine the user's global compaction threshold with a per-model override. + + Returns ``(effective_threshold, autoraise_notice)``. ``autoraise_notice`` is + ``{"model": , "from": , "to": }`` only when a Codex + autoraise (gpt-5.4/5.5 272K family or gpt-5.3-codex-spark) actually raises + the threshold, otherwise ``None``. + + The Codex overrides are *autoraises*: they must never LOWER a higher + user-configured threshold. A user who already set ``compression.threshold`` + above the raised value deliberately keeps more raw context, and silently + dropping them would both waste usable window and contradict the feature's + purpose (use more of the window). Other overrides (e.g. Arcee Trinity) + keep their existing unconditional behaviour. + """ + if model_cthresh is None: + return global_threshold, None + if is_codex_autoraise: + if model_cthresh <= global_threshold + 1e-9: + # Autoraise never lowers; keep the user's higher/equal threshold. + return global_threshold, None + return model_cthresh, { + "model": model, + "from": global_threshold, + "to": model_cthresh, + } + return model_cthresh, None + + def _normalized_custom_base_url(value: Any) -> str: if not isinstance(value, str): return "" @@ -1429,28 +1467,29 @@ def init_agent( from agent.auxiliary_client import ( _compression_threshold_for_model as _cthresh_fn, _is_codex_gpt54_or_gpt55 as _is_codex_gpt54_or_gpt55_fn, + _is_codex_spark as _is_codex_spark_fn, ) _model_cthresh = _cthresh_fn( agent.model, agent.provider, allow_codex_gpt55_autoraise=_codex_gpt55_autoraise, ) - if _model_cthresh is not None: - _prev_threshold = compression_threshold - compression_threshold = _model_cthresh - # Notify only for the Codex gpt-5.4 / gpt-5.5 autoraise (the Arcee - # Trinity override is a long-standing silent default). Skip the - # notice when the user's global threshold already meets/exceeds the - # raised value, since nothing actually changed for them. - if ( - _is_codex_gpt54_or_gpt55_fn(agent.model, agent.provider) - and _model_cthresh > _prev_threshold + 1e-9 - ): - agent._compression_threshold_autoraised = { - "model": agent.model, - "from": _prev_threshold, - "to": _model_cthresh, - } + # The Codex autoraises (gpt-5.4/5.5 272K family and gpt-5.3-codex-spark) + # apply only when they RAISE (never lower a user's higher global + # threshold). The notice is populated only when it actually fires, and + # carries the model slug so the banner names the right family. Arcee + # Trinity keeps its long-standing unconditional behaviour. + compression_threshold, agent._compression_threshold_autoraised = ( + _resolve_compression_threshold( + compression_threshold, + _model_cthresh, + model=agent.model, + is_codex_autoraise=( + _is_codex_gpt54_or_gpt55_fn(agent.model, agent.provider) + or _is_codex_spark_fn(agent.model, agent.provider) + ), + ) + ) except Exception: pass compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"} diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 1ed366d24390..2a1b5b085c22 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -417,7 +417,9 @@ def _compression_threshold_for_model( the global default (the caller passes the config flag through here). - gpt-5.3-codex-spark on the Codex OAuth route → 0.70, because the model has a native 128K window and the default 50% trigger would compact at - ~64K — wasting half the usable context. Gated by the same flag. + ~64K — wasting half the usable context. Not gated by the gpt-5.5 + opt-out flag: 128K is the model's native window, so the raise is + unambiguously correct. Returns a float in (0, 1] to override the global ``compression.threshold`` config value, or ``None`` to leave the user's config value unchanged. @@ -426,7 +428,7 @@ def _compression_threshold_for_model( return 0.75 if allow_codex_gpt55_autoraise and _is_codex_gpt54_or_gpt55(model, provider): return _CODEX_GPT54_GPT55_COMPACTION_THRESHOLD - if allow_codex_gpt55_autoraise and _is_codex_spark(model, provider): + if _is_codex_spark(model, provider): return _CODEX_SPARK_COMPACTION_THRESHOLD return None diff --git a/tests/agent/test_arcee_trinity_overrides.py b/tests/agent/test_arcee_trinity_overrides.py index edb1f7250b51..96a8fe9ff04c 100644 --- a/tests/agent/test_arcee_trinity_overrides.py +++ b/tests/agent/test_arcee_trinity_overrides.py @@ -13,6 +13,7 @@ from __future__ import annotations import pytest +from agent.agent_init import _resolve_compression_threshold from agent.auxiliary_client import ( _compression_threshold_for_model, _fixed_temperature_for_model, @@ -248,3 +249,68 @@ def test_compression_threshold_codex_spark_not_gated_by_gpt55_optout() -> None: ) == 0.70 ) + + +# ── _resolve_compression_threshold (init_agent application logic) ──────────── +# +# The Codex overrides are *autoraises*: they raise the trigger (0.85 for the +# gpt-5.4/5.5 272K family, 0.70 for spark) but must never LOWER a higher +# user-configured global threshold. + + +def test_resolve_codex_autoraise_raises_from_default() -> None: + # Default 0.50 global → raised to 0.85, notice emitted. + effective, notice = _resolve_compression_threshold( + 0.50, 0.85, model="gpt-5.5", is_codex_autoraise=True + ) + assert effective == 0.85 + assert notice == {"model": "gpt-5.5", "from": 0.50, "to": 0.85} + + +def test_resolve_codex_autoraise_never_lowers_higher_threshold() -> None: + # Regression: a user who set compression.threshold above 0.85 must keep it. + # The autoraise previously clobbered it down to 0.85 (and silently, since + # the notice was suppressed when nothing "raised"). + effective, notice = _resolve_compression_threshold( + 0.90, 0.85, model="gpt-5.5", is_codex_autoraise=True + ) + assert effective == 0.90 + assert notice is None + + +def test_resolve_codex_spark_autoraise_never_lowers_higher_threshold() -> None: + # Same never-lower contract for the spark autoraise (0.70). + effective, notice = _resolve_compression_threshold( + 0.80, 0.70, model="gpt-5.3-codex-spark", is_codex_autoraise=True + ) + assert effective == 0.80 + assert notice is None + + +def test_resolve_codex_autoraise_equal_threshold_is_noop() -> None: + # User already at exactly the raised value: keep it, no notice. + effective, notice = _resolve_compression_threshold( + 0.85, 0.85, model="gpt-5.5", is_codex_autoraise=True + ) + assert effective == 0.85 + assert notice is None + + +def test_resolve_no_override_keeps_global() -> None: + # No per-model override (model_cthresh is None) → global threshold, no notice. + effective, notice = _resolve_compression_threshold( + 0.50, None, is_codex_autoraise=False + ) + assert effective == 0.50 + assert notice is None + + +def test_resolve_non_codex_override_applies_unconditionally() -> None: + # Arcee Trinity (0.75) keeps its long-standing unconditional behaviour: it + # applies even when it lowers the user's global value, and never emits the + # codex autoraise notice. + effective, notice = _resolve_compression_threshold( + 0.90, 0.75, is_codex_autoraise=False + ) + assert effective == 0.75 + assert notice is None From fff240896120645c2f0d2b559735cf69f34106a4 Mon Sep 17 00:00:00 2001 From: Sanidhya Singh Date: Thu, 2 Jul 2026 13:15:52 +0530 Subject: [PATCH 022/610] fix(agent): dedupe Codex gpt-5.5 autoraise notice across agent inits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Codex gpt-5.5 compaction-threshold autoraise notice re-fired on every agent init. Because the gateway rebuilds the agent per inbound message, the notice spammed long-running Discord/Telegram/etc. sessions, and the only documented remedy (`compression.codex_gpt55_autoraise false`) disables the useful autoraise behavior itself. Gate both emission surfaces — the CLI startup print and the gateway `_compression_warning` replay — on a persisted per-profile marker under `$HERMES_HOME` (`.codex_gpt55_autoraise_notice`), keyed on the from→to percentages the notice displays. The notice now shows at most once per profile; the autoraise still fires and `codex_gpt55_autoraise: false` still disables it; and a later change to the raised threshold re-notifies once. Docs updated to match. --- agent/agent_init.py | 93 ++++++++++-- .../test_codex_gpt55_autoraise_notice.py | 142 +++++++++++++++++- .../context-compression-and-caching.md | 10 +- 3 files changed, 231 insertions(+), 14 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 107e78cd511e..36bea43f44b6 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -68,7 +68,7 @@ def _ra(): return run_agent -def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, float]) -> str: +def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str: """Build the one-time notice shown when Codex gpt-5.x raises compaction. ``autoraise`` is ``{"model": , "from": , "to": }``. @@ -125,6 +125,61 @@ def _resolve_compression_threshold( return model_cthresh, None +def _codex_gpt55_autoraise_notice_marker(): + """Path to the per-profile marker recording that the autoraise notice ran. + + Lives under ``$HERMES_HOME`` (which is profile-scoped) alongside the other + internal markers like ``.container-mode`` — so it is not a user-facing config + key, and every profile tracks its own notice state independently. + """ + return get_hermes_home() / ".codex_gpt55_autoraise_notice" + + +def _codex_gpt55_autoraise_notice_state(autoraise: Dict[str, Any]) -> str: + """Stable identity for one autoraise notice, keyed on what it displays. + + Uses the model slug plus the same from→to percentages the notice text + shows, so an unchanged threshold stays silent across restarts while a + later change (the user edits their global ``threshold``, or switches to a + different autoraised Codex model) re-notifies once. + """ + model = str(autoraise.get("model") or "").strip().lower().rsplit("/", 1)[-1] + from_pct = int(round(float(autoraise["from"]) * 100)) + to_pct = int(round(float(autoraise["to"]) * 100)) + return f"{model}:{from_pct}:{to_pct}" + + +def _codex_gpt55_autoraise_notice_seen(autoraise: Dict[str, Any]) -> bool: + """True if this exact autoraise notice was already shown for this profile. + + A missing/unreadable marker (or one recording a different threshold) reads + as unseen, so the notice shows. + """ + try: + current = _codex_gpt55_autoraise_notice_state(autoraise) + return _codex_gpt55_autoraise_notice_marker().read_text( + encoding="utf-8" + ).strip() == current + except (OSError, KeyError, TypeError, ValueError): + return False + + +def _record_codex_gpt55_autoraise_notice(autoraise: Dict[str, Any]) -> None: + """Persist that the autoraise notice was shown for this profile/config state. + + Best-effort: a read-only or missing ``$HERMES_HOME`` just means the notice + may show again next init, which is preferable to breaking agent init. + """ + try: + marker = _codex_gpt55_autoraise_notice_marker() + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text( + _codex_gpt55_autoraise_notice_state(autoraise), encoding="utf-8" + ) + except (OSError, KeyError, TypeError, ValueError): + pass + + def _normalized_custom_base_url(value: Any) -> str: if not isinstance(value, str): return "" @@ -1940,29 +1995,47 @@ def init_agent( agent._ollama_num_ctx, ) + # Codex gpt-5.x autoraise notice: show at most once per profile/config + # state. Without the persisted marker the notice re-fires on every agent + # init — and the gateway rebuilds the agent per inbound message, so Discord + # etc. saw it repeatedly (#54432). A change in the raised threshold (or the + # autoraised model) updates the marker state and re-notifies once. The + # config display gate (compression.codex_gpt55_autoraise_notice) still + # suppresses the banner entirely without disabling the threshold autoraise. + _autoraise = getattr(agent, "_compression_threshold_autoraised", None) + _show_autoraise_notice = ( + bool(_autoraise) + and compression_enabled + and _codex_gpt55_autoraise_notice + and not _codex_gpt55_autoraise_notice_seen(_autoraise) + ) + if not agent.quiet_mode: if compression_enabled: print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(compression_threshold*100)}% = {agent.context_compressor.threshold_tokens:,})") else: print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)") - # One-time notice when the Codex gpt-5.5 autoraise kicked in, with the - # exact opt-back-out command. Printed inline at startup for CLI users; - # gateway users get the same text replayed via _compression_warning on - # turn 1 (set below, after the warning slot is initialized). - _autoraise = getattr(agent, "_compression_threshold_autoraised", None) - if _autoraise and compression_enabled and _codex_gpt55_autoraise_notice: + # Notice with the exact opt-back-out command. Printed inline at startup + # for CLI users; gateway users get the same text replayed via + # _compression_warning on turn 1 (set below). + if _show_autoraise_notice: print(_build_codex_gpt5_autoraise_notice(_autoraise)) # Check immediately so CLI users see the warning at startup. # Gateway status_callback is not yet wired, so any warning is stored # in _compression_warning and replayed in the first run_conversation(). agent._compression_warning = None - # Gateway parity for the Codex gpt-5.5 autoraise notice: the startup print + # Gateway parity for the Codex gpt-5.x autoraise notice: the startup print # above only reaches the CLI, so stash the same text here to be replayed # through status_callback on the first turn (Telegram/Discord/Slack/etc.). - _autoraise = getattr(agent, "_compression_threshold_autoraised", None) - if _autoraise and compression_enabled and _codex_gpt55_autoraise_notice: + if _show_autoraise_notice: agent._compression_warning = _build_codex_gpt5_autoraise_notice(_autoraise) + + # Mark shown so repeated inits in this profile (e.g. every gateway message) + # stay silent. Recorded once, whether the notice went to the CLI print or + # the gateway replay slot. + if _show_autoraise_notice: + _record_codex_gpt55_autoraise_notice(_autoraise) # Lazy feasibility check: deferred to the first turn that approaches the # compression threshold. Running it eagerly here costs ~400ms cold (network # probe of the auxiliary provider chain + /models lookup) on every agent diff --git a/tests/agent/test_codex_gpt55_autoraise_notice.py b/tests/agent/test_codex_gpt55_autoraise_notice.py index 46e733a4e112..2638407fea67 100644 --- a/tests/agent/test_codex_gpt55_autoraise_notice.py +++ b/tests/agent/test_codex_gpt55_autoraise_notice.py @@ -1,4 +1,18 @@ -"""Regression tests for the Codex gpt-5.5 autoraise notice gate.""" +"""Regression tests for the Codex gpt-5.x autoraise notice gate. + +Covers two layers: + +1. The config display gate (``compression.codex_gpt55_autoraise_notice``) — + suppresses the banner without disabling the threshold autoraise. +2. The per-profile dedupe marker (#54432) — the notice must show at most once + per profile/config state. Before the fix it re-fired on every agent init, + and because the gateway rebuilds the agent per inbound message it spammed + Discord etc. The gate persists a marker under ``$HERMES_HOME`` + (profile-scoped, isolated to a tempdir by the conftest autouse fixture) + keyed on the model slug + displayed from→to percentages, so an unchanged + threshold stays silent across restarts while a changed threshold (or a + different autoraised Codex model) re-notifies once. +""" from __future__ import annotations @@ -6,9 +20,22 @@ import contextlib import io from pathlib import Path +import pytest + +from hermes_constants import get_hermes_home from hermes_state import SessionDB from run_agent import AIAgent +from agent.agent_init import ( + _codex_gpt55_autoraise_notice_marker, + _codex_gpt55_autoraise_notice_seen, + _codex_gpt55_autoraise_notice_state, + _record_codex_gpt55_autoraise_notice, +) + +# The dict agent_init stashes when the Codex gpt-5.5 override fires. +AUTORAISE = {"model": "gpt-5.5", "from": 0.50, "to": 0.85} + def _config(*, show_notice: bool) -> dict: return { @@ -57,6 +84,9 @@ def _threshold_ratio(agent: AIAgent) -> float: return round(compressor.threshold_tokens / compressor.context_length, 2) +# ── config display gate ────────────────────────────────────────────────────── + + def test_codex_gpt55_autoraise_notice_enabled_by_default(monkeypatch, tmp_path): agent, stdout = _make_codex_agent(monkeypatch, tmp_path, show_notice=True) @@ -75,3 +105,113 @@ def test_codex_gpt55_autoraise_notice_can_be_suppressed_without_disabling_autora assert _threshold_ratio(agent) == 0.85 assert getattr(agent, "_compression_warning") is None assert "auto-compaction was raised" not in stdout + + +def test_codex_gpt55_autoraise_notice_deduped_across_agent_inits(monkeypatch, tmp_path): + # Gateway spam scenario (#54432): the gateway rebuilds the agent per + # inbound message. The first init shows the notice; the second stays + # silent because the per-profile marker was recorded. + agent1, stdout1 = _make_codex_agent(monkeypatch, tmp_path, show_notice=True) + assert "auto-compaction was raised" in stdout1 + assert getattr(agent1, "_compression_warning") is not None + + agent2, stdout2 = _make_codex_agent(monkeypatch, tmp_path, show_notice=True) + assert _threshold_ratio(agent2) == 0.85 # autoraise still applies + assert "auto-compaction was raised" not in stdout2 + assert getattr(agent2, "_compression_warning") is None + + +# ── per-profile dedupe marker (#54432) ─────────────────────────────────────── + + +def test_marker_lives_under_hermes_home() -> None: + marker = _codex_gpt55_autoraise_notice_marker() + assert marker.parent == get_hermes_home() + assert marker.name == ".codex_gpt55_autoraise_notice" + + +def test_state_keyed_on_model_and_displayed_percentages() -> None: + # Same percentages the notice text renders (int(round(ratio * 100))), + # prefixed with the bare model slug. + assert _codex_gpt55_autoraise_notice_state(AUTORAISE) == "gpt-5.5:50:85" + assert ( + _codex_gpt55_autoraise_notice_state( + {"model": "openai/gpt-5.4", "from": 0.75, "to": 0.85} + ) + == "gpt-5.4:75:85" + ) + + +def test_unseen_before_anything_is_recorded() -> None: + assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is False + + +def test_seen_after_record() -> None: + assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is False + _record_codex_gpt55_autoraise_notice(AUTORAISE) + # A "restart" is just another call: the marker persists on disk. + assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is True + + +def test_changed_threshold_renotifies_once() -> None: + _record_codex_gpt55_autoraise_notice(AUTORAISE) + assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is True + # User raises their global threshold -> "from" changes -> notice re-fires. + changed = {"model": "gpt-5.5", "from": 0.60, "to": 0.85} + assert _codex_gpt55_autoraise_notice_seen(changed) is False + _record_codex_gpt55_autoraise_notice(changed) + assert _codex_gpt55_autoraise_notice_seen(changed) is True + # And the old state is now considered unseen (marker moved forward). + assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is False + + +def test_changed_model_renotifies_once() -> None: + # Switching to a different autoraised Codex model re-fires the notice + # (the banner names the model, so it displays new information). + _record_codex_gpt55_autoraise_notice(AUTORAISE) + other_model = {"model": "gpt-5.4", "from": 0.50, "to": 0.85} + assert _codex_gpt55_autoraise_notice_seen(other_model) is False + _record_codex_gpt55_autoraise_notice(other_model) + assert _codex_gpt55_autoraise_notice_seen(other_model) is True + + +def test_record_is_idempotent() -> None: + _record_codex_gpt55_autoraise_notice(AUTORAISE) + _record_codex_gpt55_autoraise_notice(AUTORAISE) + assert ( + _codex_gpt55_autoraise_notice_marker().read_text(encoding="utf-8") + == "gpt-5.5:50:85" + ) + + +def test_malformed_marker_reads_as_unseen() -> None: + marker = _codex_gpt55_autoraise_notice_marker() + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("not-a-state", encoding="utf-8") + assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is False + + +@pytest.mark.parametrize("bad", [{}, {"from": 0.5}, {"from": None, "to": None}]) +def test_seen_tolerates_malformed_autoraise(bad) -> None: + # Never raises even if the stashed dict is missing/garbage keys. + assert _codex_gpt55_autoraise_notice_seen(bad) is False + + +def test_full_init_gate_shows_once_then_stays_silent() -> None: + # Mirror the decision agent_init makes on each build: + # show = bool(autoraise) and compression_enabled and not seen(autoraise) + def decide(compression_enabled: bool) -> bool: + show = ( + bool(AUTORAISE) + and compression_enabled + and not _codex_gpt55_autoraise_notice_seen(AUTORAISE) + ) + if show: + _record_codex_gpt55_autoraise_notice(AUTORAISE) + return show + + # First init (any surface) shows; every subsequent init in this profile + # stays silent — the gateway spam scenario from the issue. + assert decide(compression_enabled=True) is True + assert decide(compression_enabled=True) is False + assert decide(compression_enabled=True) is False diff --git a/website/docs/developer-guide/context-compression-and-caching.md b/website/docs/developer-guide/context-compression-and-caching.md index 907731f846a9..d9f4f6aac054 100644 --- a/website/docs/developer-guide/context-compression-and-caching.md +++ b/website/docs/developer-guide/context-compression-and-caching.md @@ -113,9 +113,13 @@ The ChatGPT Codex OAuth backend hard-caps gpt-5.5 at a **272K** context window GitHub Copilot). At the default 50% trigger, compaction would fire at ~136K — half the window the model can actually use. When the active route is Codex OAuth (`provider: openai-codex`) and the model is gpt-5.5, Hermes raises the -trigger to **85%** (~231K) and prints a one-time notice with the opt-out -command. Only this exact route is affected; gpt-5.5 on any other provider keeps -your global `threshold`. To opt back down to the global value: +trigger to **85%** (~231K) and shows a notice with the opt-out command. The +notice is shown once per profile — a marker under `$HERMES_HOME` +(`.codex_gpt55_autoraise_notice`) records that it ran, so repeated agent/session +inits (e.g. every inbound gateway message) don't re-emit it; if the raised +threshold later changes it re-notifies once. Only this exact route is affected; +gpt-5.5 on any other provider keeps your global `threshold`. To opt back down to +the global value: ```bash hermes config set compression.codex_gpt55_autoraise false From 60391d0eef1fb96b6c3ea94531576874d67b09b1 Mon Sep 17 00:00:00 2001 From: AIalliAI <285906080+AIalliAI@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:50:06 +0000 Subject: [PATCH 023/610] fix(agent): don't apply Codex gpt-5.5 autoraise notice when an external context engine is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When context.engine selects a plugin engine (e.g. LCM), the host compression threshold — including the Codex gpt-5.5 50% -> 85% autoraise — only configures the built-in ContextCompressor and never reaches the plugin. The autoraise notice still fired, telling the user auto-compaction was raised when nothing actually changed, and the startup context-limit line printed the host percent next to the engine's own threshold_tokens, contradicting itself. - Clear _compression_threshold_autoraised when a plugin engine is selected, suppressing both the CLI startup notice and the gateway turn-1 replay via _compression_warning. - Print the active engine's own threshold_percent in the startup context-limit line so percent and token count agree. - Built-in behavior is preserved, including the fallback path where a configured engine fails to load and the built-in compressor takes over. Fixes #44439 --- agent/agent_init.py | 14 ++- .../test_plugin_context_engine_init.py | 98 +++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 36bea43f44b6..32a062594411 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1773,6 +1773,12 @@ def init_agent( if _selected_engine is not None: agent.context_compressor = _selected_engine + # External engines own compaction policy: the host compression + # threshold (including the Codex gpt-5.5 autoraise above) only + # configures the built-in ContextCompressor and never reaches the + # plugin, so the autoraise notice would announce a change that does + # not apply. Drop it. (#44439) + agent._compression_threshold_autoraised = None # Resolve context_length for plugin engines — mirrors switch_model() path from agent.model_metadata import get_model_context_length _plugin_ctx_len = get_model_context_length( @@ -2012,7 +2018,13 @@ def init_agent( if not agent.quiet_mode: if compression_enabled: - print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(compression_threshold*100)}% = {agent.context_compressor.threshold_tokens:,})") + # Report the active engine's own threshold — for a plugin engine + # the host compression_threshold is not in effect, and mixing the + # two printed a percent that contradicted the token count. (#44439) + _active_threshold_pct = getattr( + agent.context_compressor, "threshold_percent", compression_threshold + ) + print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,})") else: print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)") # Notice with the exact opt-back-out command. Printed inline at startup diff --git a/tests/run_agent/test_plugin_context_engine_init.py b/tests/run_agent/test_plugin_context_engine_init.py index 7285cb1f625c..f4b29ef00936 100644 --- a/tests/run_agent/test_plugin_context_engine_init.py +++ b/tests/run_agent/test_plugin_context_engine_init.py @@ -139,3 +139,101 @@ def test_plugin_engine_update_model_args(): assert "model" in kw assert "provider" in kw assert "api_mode" in kw + + +def _codex_agent_kwargs(): + return dict( + model="gpt-5.5", + provider="openai-codex", + api_key="test-key-1234567890", + base_url="https://chatgpt.com/backend-api/codex", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + +def test_codex_gpt55_autoraise_suppressed_for_plugin_engine(): + """Codex gpt-5.5 autoraise must not fire when an external engine is active. + + Regression test for #44439 — the host compression threshold (including + the 0.85 autoraise) never reaches a plugin context engine, so the notice + announced a change that did not apply. + """ + engine = _StubEngine() + cfg = { + "context": {"engine": "stub"}, + "compression": {"enabled": True, "threshold": 0.75}, + "agent": {}, + } + + with ( + patch("hermes_cli.config.load_config", return_value=cfg), + patch("plugins.context_engine.load_context_engine", return_value=engine), + patch("agent.model_metadata.get_model_context_length", return_value=272_000), + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + from run_agent import AIAgent + + agent = AIAgent(**_codex_agent_kwargs()) + + assert agent.context_compressor is engine + assert agent._compression_threshold_autoraised is None + assert agent._compression_warning is None + # The engine's own policy is untouched by the host threshold. + assert engine.threshold_percent == 0.75 + + +def test_codex_gpt55_autoraise_still_applies_to_builtin_compressor(): + """Stock built-in compressor keeps the 50% → 85% Codex gpt-5.5 autoraise.""" + cfg = { + "compression": {"enabled": True, "threshold": 0.50}, + "agent": {}, + } + + with ( + patch("hermes_cli.config.load_config", return_value=cfg), + patch("agent.context_compressor.get_model_context_length", return_value=272_000), + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + from run_agent import AIAgent + + agent = AIAgent(**_codex_agent_kwargs()) + + assert agent._compression_threshold_autoraised == {"from": 0.50, "to": 0.85} + assert agent.context_compressor.threshold_percent == 0.85 + # Gateway parity: the notice is stashed for replay on turn 1. + assert agent._compression_warning and "85%" in agent._compression_warning + + +def test_codex_gpt55_autoraise_applies_when_plugin_engine_missing(): + """If the configured engine fails to load, the built-in compressor is + active and the autoraise (plus its notice) must still apply.""" + cfg = { + "context": {"engine": "no-such-engine"}, + "compression": {"enabled": True, "threshold": 0.50}, + "agent": {}, + } + + with ( + patch("hermes_cli.config.load_config", return_value=cfg), + patch( + "plugins.context_engine.load_context_engine", + side_effect=ValueError("not found"), + ), + patch("hermes_cli.plugins.get_plugin_context_engine", return_value=None), + patch("agent.context_compressor.get_model_context_length", return_value=272_000), + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + from run_agent import AIAgent + + agent = AIAgent(**_codex_agent_kwargs()) + + assert agent._compression_threshold_autoraised == {"from": 0.50, "to": 0.85} + assert agent.context_compressor.threshold_percent == 0.85 From 5de42325db8456acd44be0744b551a5c0bab5108 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:10:21 -0700 Subject: [PATCH 024/610] test: expect model slug in autoraise notice dict (follow-up to gpt-5.4 extension) --- tests/run_agent/test_plugin_context_engine_init.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/run_agent/test_plugin_context_engine_init.py b/tests/run_agent/test_plugin_context_engine_init.py index f4b29ef00936..2c7e53e13497 100644 --- a/tests/run_agent/test_plugin_context_engine_init.py +++ b/tests/run_agent/test_plugin_context_engine_init.py @@ -204,7 +204,7 @@ def test_codex_gpt55_autoraise_still_applies_to_builtin_compressor(): agent = AIAgent(**_codex_agent_kwargs()) - assert agent._compression_threshold_autoraised == {"from": 0.50, "to": 0.85} + assert agent._compression_threshold_autoraised == {"model": "gpt-5.5", "from": 0.50, "to": 0.85} assert agent.context_compressor.threshold_percent == 0.85 # Gateway parity: the notice is stashed for replay on turn 1. assert agent._compression_warning and "85%" in agent._compression_warning @@ -235,5 +235,5 @@ def test_codex_gpt55_autoraise_applies_when_plugin_engine_missing(): agent = AIAgent(**_codex_agent_kwargs()) - assert agent._compression_threshold_autoraised == {"from": 0.50, "to": 0.85} + assert agent._compression_threshold_autoraised == {"model": "gpt-5.5", "from": 0.50, "to": 0.85} assert agent.context_compressor.threshold_percent == 0.85 From dd7198e71dbecc186fcda6c1bba8c8fec2af1623 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:11:33 -0700 Subject: [PATCH 025/610] chore: add tanmayxchoudhary to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 1214d44cbcb5..f6d3c4271a72 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -285,6 +285,7 @@ AUTHOR_MAP = { "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", "bigstar0920@gmail.com": "bigstar0920", + "hello@tanmaychoudhary.com": "tanmayxchoudhary", "285906080+AIalliAI@users.noreply.github.com": "AIalliAI", "waseemshahwan@users.noreply.github.com": "waseemshahwan", "hellno@users.noreply.github.com": "hellno", From 70c6ae609ed643f2d43cdac7e3fc8b6c34411d12 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:46:40 -0700 Subject: [PATCH 026/610] fix(tui): stop hermes --tui -m from persisting the model globally (#59805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The -m flag seeds HERMES_MODEL/HERMES_INFERENCE_MODEL for the launched TUI process only. But the per-turn config sync (_sync_agent_model_with_config) computed its target via _config_model_target(), which fell back to those env vars whenever config.yaml had no model.default — the normal state for custom-provider-only setups. The sync then replayed the -m model as a /model switch, and with model.persist_switch_by_default (default true) _persist_model_switch wrote model.default/provider/base_url into config.yaml. A one-shot CLI flag became the permanent global model, visible in every new session and every model picker. Two-sided fix: - _config_model_target() no longer falls back to the env seed. Empty model = config expresses no preference = sync is a no-op. The agent keeps the session-scoped -m model; config.yaml edits still sync. - _apply_model_switch() gains persist_override; all three internal callers (config sync, /moa one-shot swap, /moa post-turn restore) pass persist_override=False so session-mechanical switches can never write config.yaml regardless of the persist-by-default setting. User-typed /model keeps its existing flag/config behavior. E2E-verified against an isolated HERMES_HOME with a custom-provider-only config + -m env seed: sync no longer fires, config.yaml byte-identical, _resolve_model() still returns the seed for the session's own agent. --- tests/test_tui_gateway_server.py | 78 +++++++++++++++++++++++++++++++- tui_gateway/server.py | 44 ++++++++++++++---- 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index b48e6c36ca9f..a74c7d2a322c 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1416,7 +1416,11 @@ def test_config_sync_switches_unpinned_session(monkeypatch): ( "sid", "new/model --provider nous", - {"confirm_expensive_model": True, "pin_session_override": False}, + { + "confirm_expensive_model": True, + "pin_session_override": False, + "persist_override": False, + }, ) ] assert session["config_model_seen"] == ("new/model", "nous") @@ -1536,6 +1540,78 @@ def test_config_sync_config_wins_over_env_seed(monkeypatch): assert session["config_model_seen"] == ("new/model", "") +def test_config_sync_ignores_env_seed_without_config_model(monkeypatch): + # `hermes --tui -m ` sets HERMES_MODEL/HERMES_INFERENCE_MODEL as a + # launch-scoped seed. When config.yaml has NO model.default (typical + # custom-provider-only setup), the sync must NOT adopt the env seed as a + # config target — doing so replayed the -m flag as a /model switch and + # (with persist_switch_by_default=True) wrote it into config.yaml + # permanently. + monkeypatch.setenv("HERMES_MODEL", "one-shot/model") + monkeypatch.setenv("HERMES_INFERENCE_MODEL", "one-shot/model") + monkeypatch.setattr( + server, "_load_cfg", lambda: {"model": {"provider": "custom:mylocal"}} + ) + session = _sync_test_session() + monkeypatch.setattr( + server, + "_apply_model_switch", + lambda *a, **k: pytest.fail("env seed must not trigger a config sync switch"), + ) + + server._sync_agent_model_with_config("sid", session) + + +def test_config_model_target_never_reads_env(monkeypatch): + monkeypatch.setenv("HERMES_MODEL", "seed/model") + monkeypatch.setenv("HERMES_INFERENCE_MODEL", "seed/model") + monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"provider": "nous"}}) + + assert server._config_model_target() == ("", "nous") + + +def test_apply_model_switch_persist_override_false_never_persists(monkeypatch): + # Internal callers (config sync, /moa one-shot + restore) pass + # persist_override=False; even with persist_switch_by_default=True the + # switch must not write config.yaml. + import types as _types + + result = _types.SimpleNamespace( + success=True, + new_model="new/model", + target_provider="nous", + base_url="", + api_key="key", + api_mode="chat_completions", + warning_message="", + model_info=None, + error_message="", + ) + monkeypatch.setattr( + "hermes_cli.model_switch.switch_model", lambda **kw: result + ) + monkeypatch.setattr( + "hermes_cli.model_switch.resolve_persist_behavior", + lambda *a: pytest.fail("persist_override must bypass resolve_persist_behavior"), + ) + monkeypatch.setattr( + server, "_persist_model_switch", + lambda _r: pytest.fail("persist_override=False must not persist"), + ) + monkeypatch.setattr( + "hermes_cli.model_cost_guard.expensive_model_warning", + lambda *a, **k: None, + ) + session = {"agent": None} + + out = server._apply_model_switch( + "sid", session, "new/model --provider nous", persist_override=False + ) + + assert out["value"] == "new/model" + assert session["model_override"]["model"] == "new/model" + + def test_startup_runtime_uses_tui_provider_env(monkeypatch): monkeypatch.setenv("HERMES_MODEL", "nous/hermes-test") monkeypatch.setenv("HERMES_TUI_PROVIDER", "nous") diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7293aa70ff5e..1b2156f4c20b 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1994,14 +1994,14 @@ def _resolve_model() -> str: def _config_model_target() -> tuple[str, str]: - """(model, provider) currently selected by config (env as fallback). + """(model, provider) currently selected by config.yaml — and ONLY config. - config.yaml wins over HERMES_MODEL / HERMES_INFERENCE_MODEL here, the - reverse of `_resolve_model()`'s startup order. Those env vars are a - provision-time seed (hosted instances set HERMES_INFERENCE_MODEL in the - container env); if they outranked config.yaml, the per-turn sync would - stay pinned to the seed forever and dashboard/CLI model changes would - never reach an open chat — the exact bug this sync exists to fix. + Unlike `_resolve_model()`, this never reads HERMES_MODEL / + HERMES_INFERENCE_MODEL. Those env vars are a launch-scoped seed + (`hermes --tui -m `, hosted-instance provisioning); if they + fed the per-turn sync, the seed would be replayed as a /model switch + and persisted globally, or would pin the session so dashboard/CLI + model changes never reach an open chat. """ cfg_model = _load_cfg().get("model") model = "" @@ -2013,8 +2013,15 @@ def _config_model_target() -> tuple[str, str]: provider = "" elif isinstance(cfg_model, str): model = cfg_model.strip() - if not model: - model = _resolve_model() + # No fallback to _resolve_model() here: that reads HERMES_MODEL / + # HERMES_INFERENCE_MODEL, which `hermes --tui -m ` sets as a + # session-scoped seed for THIS launch. When config.yaml has no + # model.default (custom-provider-only setups), falling back to the env + # seed made the per-turn sync treat the -m flag as "the configured + # model" and replay it as a /model switch — which then persisted the + # one-shot flag into config.yaml globally (#-m leak). An empty model + # simply means "config expresses no preference": the sync is a no-op + # and the agent keeps whatever it was built with. return model, provider @@ -2660,6 +2667,7 @@ def _apply_model_switch( confirm_expensive_model: bool = False, pin_session_override: bool = True, parsed_flags: tuple[str, str, bool, bool, bool] | None = None, + persist_override: bool | None = None, ) -> dict: from hermes_cli.model_switch import ( parse_model_flags, @@ -2677,7 +2685,11 @@ def _apply_model_switch( _force_refresh, is_session, ) = parsed_flags - persist_global = resolve_persist_behavior(is_global_flag, is_session) + persist_global = ( + persist_override + if persist_override is not None + else resolve_persist_behavior(is_global_flag, is_session) + ) if not model_input: raise ValueError("model value required") @@ -2869,6 +2881,12 @@ def _sync_agent_model_with_config(sid: str, session: dict) -> None: raw, confirm_expensive_model=True, pin_session_override=False, + # This sync ADOPTS a config.yaml change into the live session; it + # must never write config back. Without this, the flag/config + # default (persist_switch_by_default=True) re-persisted whatever + # target the sync computed — the path that leaked `hermes --tui -m` + # into config.yaml as the permanent global model. + persist_override=False, ) except Exception as e: _emit( @@ -8700,6 +8718,9 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: _raw, confirm_expensive_model=False, pin_session_override=bool(_prev_override), + # Session-internal restore after the /moa + # one-shot — never persist to config.yaml. + persist_override=False, ) except Exception as _moa_restore_exc: logger.warning( @@ -11559,6 +11580,9 @@ def _(rid, params: dict) -> dict: f"{preset} --provider moa", confirm_expensive_model=False, pin_session_override=True, + # One-shot turn-scoped swap — never persist the MoA + # virtual provider to config.yaml. + persist_override=False, ) except Exception as exc: session.pop("moa_one_shot_restore", None) From 4131ec380babc55a6a45ef573459fbac28367b96 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:08:45 -0600 Subject: [PATCH 027/610] fix(tui): support model picker refresh --- .../src/__tests__/createSlashHandler.test.ts | 9 +++++++++ ui-tui/src/app/interfaces.ts | 2 +- ui-tui/src/app/slash/commands/session.ts | 4 ++++ ui-tui/src/components/appOverlays.tsx | 1 + ui-tui/src/components/modelPicker.tsx | 18 +++++++++++++++--- 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index c0487fad0536..ca1af4cd9abe 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -183,6 +183,15 @@ describe('createSlashHandler', () => { }) }) + it('opens the model picker with refresh for /model --refresh', () => { + patchUiState({ sid: 'sid-abc' }) + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/model --refresh')).toBe(true) + expect(getOverlayState().modelPicker).toEqual({ refresh: true }) + expect(ctx.gateway.rpc).not.toHaveBeenCalled() + }) + it('honors TUI picker session scope without adding --global', async () => { patchUiState({ sid: 'sid-abc' }) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 65285467d9af..85586f66aaa7 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -135,7 +135,7 @@ export interface OverlayState { clarify: ClarifyReq | null confirm: ConfirmReq | null journey: boolean - modelPicker: boolean + modelPicker: boolean | { refresh?: boolean } pager: null | PagerState petPicker: boolean pluginsHub: boolean diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 94e1b38c5de4..6b1fe55481bc 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -73,6 +73,10 @@ export const sessionCommands: SlashCommand[] = [ return patchOverlayState({ modelPicker: true }) } + if (arg.trim() === '--refresh') { + return patchOverlayState({ modelPicker: { refresh: true } }) + } + const switchModel = (confirmExpensiveModel = false) => ctx.gateway .rpc('config.set', { diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 9ab568f32ea6..24b45d33e248 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -180,6 +180,7 @@ export function FloatingOverlays({ patchOverlayState({ modelPicker: false })} onSelect={onModelSelect} sessionId={sid} diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index c36a1505908d..8752dd371a23 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -27,7 +27,15 @@ export function providerIndexAfterClearingFilter(providerRows: ProviderRow[], pr return providerRows.findIndex(row => row.provider.slug === provider.slug) } -export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) { +export function ModelPicker({ + allowPersistGlobal = true, + gw, + initialRefresh = false, + onCancel, + onSelect, + sessionId, + t +}: ModelPickerProps) { const [providers, setProviders] = useState([]) const [currentModel, setCurrentModel] = useState('') const [err, setErr] = useState('') @@ -50,7 +58,10 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect, const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) useEffect(() => { - gw.request('model.options', sessionId ? { session_id: sessionId } : {}) + gw.request('model.options', { + ...(sessionId ? { session_id: sessionId } : {}), + ...(initialRefresh ? { refresh: true } : {}) + }) .then(raw => { const r = asRpcResult(raw) @@ -79,7 +90,7 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect, setErr(rpcErrorMessage(e)) setLoading(false) }) - }, [gw, sessionId]) + }, [gw, initialRefresh, sessionId]) const names = useMemo(() => providerDisplayNames(providers), [providers]) @@ -677,6 +688,7 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect, interface ModelPickerProps { allowPersistGlobal?: boolean gw: GatewayClient + initialRefresh?: boolean onCancel: () => void onSelect: (value: string) => void sessionId: string | null From 4b4f0588605a76bcd7ada14fba509f049c4d58cc Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:06:37 -0600 Subject: [PATCH 028/610] fix(tui): probe active custom model provider --- hermes_cli/inventory.py | 6 ++ hermes_cli/model_switch.py | 54 +++++++++++---- hermes_cli/web_server.py | 1 + tests/hermes_cli/test_inventory.py | 18 +++++ .../test_model_switch_custom_providers.py | 66 +++++++++++++++++++ tests/test_tui_gateway_server.py | 2 + tui_gateway/server.py | 1 + 7 files changed, 137 insertions(+), 11 deletions(-) diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 51569c73b2cf..ad279ca4f722 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -119,6 +119,7 @@ def build_models_payload( force_fresh_nous_tier: bool = False, refresh: bool = False, probe_custom_providers: bool = True, + probe_current_custom_provider: bool = False, max_models: int | None = None, ) -> dict: """Build the ``{providers, model, provider}`` shape every consumer @@ -155,6 +156,10 @@ def build_models_payload( opens should leave this false unless the user explicitly refreshes; the row can still render its configured model immediately, and slow/offline local endpoints no longer block the dialog. + - ``probe_current_custom_provider``: when ``probe_custom_providers`` is + false, still live-probe the current custom endpoint. This keeps normal + GUI/TUI picker opens fast while making the active custom provider's model + list match the classic CLI picker. """ from hermes_cli.model_switch import list_authenticated_providers @@ -168,6 +173,7 @@ def build_models_payload( max_models=max_models, refresh=refresh, probe_custom_providers=probe_custom_providers, + probe_current_custom_provider=probe_current_custom_provider, ) moa_row = _moa_provider_row(ctx.current_provider) diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 638ecf9d50f7..905cb46cd903 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1455,6 +1455,7 @@ def list_authenticated_providers( current_model: str = "", refresh: bool = False, probe_custom_providers: bool = True, + probe_current_custom_provider: bool = False, ) -> List[dict]: """Detect which providers have credentials and list their curated models. @@ -1486,6 +1487,11 @@ def list_authenticated_providers( custom OpenAI-compatible endpoints. Keep the default true for CLI parity; GUI picker opens can pass false to show configured models immediately without waiting on offline local endpoints. + + ``probe_current_custom_provider`` is the middle ground for GUI picker + opens: probe only the currently-selected custom endpoint so its model list + matches the active provider without blocking on every saved/offline custom + endpoint. """ import os from agent.models_dev import ( @@ -1515,6 +1521,12 @@ def list_authenticated_providers( results: List[dict] = [] seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545) seen_mdev_ids: set = set() # prevent duplicate entries for aliases (e.g. kimi-coding + kimi-coding-cn) + _current_provider_norm = str(current_provider or "").strip().lower() + _current_base_url_norm = str(current_base_url or "").strip().rstrip("/").lower() + + def _can_probe_custom_provider(*, row_is_current: bool) -> bool: + return bool(probe_custom_providers or (probe_current_custom_provider and row_is_current)) + # Effective base URLs of every built-in row we emit (normalized lower+rstrip). # Section 4 uses this to hide ``custom_providers`` entries that point at the # same endpoint as a built-in (e.g. a user-defined "my-dashscope" on @@ -2021,7 +2033,19 @@ def list_authenticated_providers( if isinstance(discover, str): discover = discover.lower() not in {"false", "no", "0"} has_explicit_models = bool(models_list) - should_probe = probe_custom_providers and bool(api_url) and discover and ( + _ep_url_norm = str(api_url).strip().rstrip("/").lower() + _ep_slug_norm = str(ep_name).strip().lower() + _ep_custom_slug_norm = custom_provider_slug(display_name).lower() + _ep_is_current = ( + _ep_slug_norm == _current_provider_norm + or _ep_custom_slug_norm == _current_provider_norm + or ( + _current_provider_norm == "custom" + and bool(_current_base_url_norm) + and _ep_url_norm == _current_base_url_norm + ) + ) + should_probe = _can_probe_custom_provider(row_is_current=_ep_is_current) and bool(api_url) and discover and ( bool(api_key) or not has_explicit_models ) if should_probe: @@ -2040,7 +2064,7 @@ def list_authenticated_providers( results.append({ "slug": ep_name, "name": display_name, - "is_current": ep_name == current_provider, + "is_current": _ep_is_current, "is_user_defined": True, "models": models_list, "total_models": len(models_list) if models_list else 0, @@ -2064,7 +2088,6 @@ def list_authenticated_providers( # picker to render, but the gateway only passes this current model slice to # list_authenticated_providers(). Surface the active endpoint explicitly so # /model does not look like it ignored config.yaml. - _current_provider_norm = str(current_provider or "").strip().lower() if ( _current_provider_norm == "custom" and current_base_url @@ -2081,6 +2104,15 @@ def list_authenticated_providers( ) ): _models = [current_model] if current_model else [] + if _can_probe_custom_provider(row_is_current=True): + try: + from hermes_cli.models import fetch_api_models + + _live_models = fetch_api_models("", str(current_base_url).strip().rstrip("/")) + if _live_models: + _models = _live_models + except Exception: + pass results.append({ "slug": "custom", "name": "Custom endpoint", @@ -2203,7 +2235,6 @@ def list_authenticated_providers( groups[group_key]["models"].append(model_id) _section4_emitted_slugs: set = set() - _current_base_url_norm = str(current_base_url or "").strip().rstrip("/").lower() _current_base_url_group_count = sum( 1 for _grp in groups.values() @@ -2275,8 +2306,14 @@ def list_authenticated_providers( # api_key is present. This supports endpoints that expose a # full aggregator catalog via /models but only serve a subset # (parity with section 3's user ``providers:`` behaviour). + _grp_is_current = slug.lower() == _current_provider_norm or ( + _current_provider_norm == "custom" + and bool(_current_base_url_norm) + and _grp_url_norm == _current_base_url_norm + and _current_base_url_group_count == 1 + ) should_probe = ( - probe_custom_providers + _can_probe_custom_provider(row_is_current=_grp_is_current) and bool(api_url) and (bool(api_key) or not grp["models"]) and grp.get("discover_models", True) @@ -2298,12 +2335,7 @@ def list_authenticated_providers( results.append({ "slug": slug, "name": grp["name"], - "is_current": slug == current_provider or ( - current_provider == "custom" - and bool(_current_base_url_norm) - and _grp_url_norm == _current_base_url_norm - and _current_base_url_group_count == 1 - ), + "is_current": _grp_is_current, "is_user_defined": True, "models": grp["models"], "total_models": len(grp["models"]), diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index dc14423dcc2e..a5292224cd55 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4305,6 +4305,7 @@ def get_model_options(profile: Optional[str] = None, refresh: bool = False): capabilities=True, refresh=bool(refresh), probe_custom_providers=bool(refresh), + probe_current_custom_provider=not bool(refresh), ) except HTTPException: raise diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index 386cda0e0da3..09a839f1c2e6 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -258,6 +258,24 @@ def test_build_models_payload_can_skip_custom_provider_probes(): assert mock_list.call_args.kwargs["probe_custom_providers"] is False +def test_build_models_payload_can_probe_only_current_custom_provider(): + ctx = _empty_ctx() + rows = [] + with patch( + "hermes_cli.model_switch.list_authenticated_providers", + return_value=rows, + ) as mock_list: + build_models_payload( + ctx, + probe_custom_providers=False, + probe_current_custom_provider=True, + ) + + mock_list.assert_called_once() + assert mock_list.call_args.kwargs["probe_custom_providers"] is False + assert mock_list.call_args.kwargs["probe_current_custom_provider"] is True + + def test_list_authenticated_providers_force_fresh_is_keyword_only(): """``force_fresh_nous_tier`` must be keyword-only on the public listing API. diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index a1ec07117f22..4d80469b975b 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -69,6 +69,49 @@ def test_list_authenticated_providers_can_skip_custom_provider_live_probe(monkey assert row["total_models"] == 1 +def test_list_authenticated_providers_can_probe_only_current_custom_provider(monkeypatch): + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + + calls = [] + + def fetch(api_key, api_url, **kwargs): + calls.append(api_url) + if api_url == "http://active.local/v1": + return ["active-a", "active-b"] + raise AssertionError(f"unexpected probe for {api_url}") + + monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch) + + providers = list_authenticated_providers( + current_provider="custom:active-proxy", + user_providers={}, + custom_providers=[ + { + "name": "Active Proxy", + "base_url": "http://active.local/v1", + "api_key": "sk-active", + "model": "configured-active", + }, + { + "name": "Offline Proxy", + "base_url": "http://offline.local/v1", + "api_key": "sk-offline", + "model": "configured-offline", + }, + ], + probe_custom_providers=False, + probe_current_custom_provider=True, + ) + + active = next(p for p in providers if p["slug"] == "custom:active-proxy") + offline = next(p for p in providers if p["slug"] == "custom:offline-proxy") + assert calls == ["http://active.local/v1"] + assert active["is_current"] is True + assert active["models"] == ["active-a", "active-b"] + assert offline["models"] == ["configured-offline"] + + def test_resolve_provider_full_finds_named_custom_provider(): """Explicit /model --provider should resolve saved custom_providers entries.""" resolved = resolve_provider_full( @@ -119,6 +162,29 @@ def test_list_authenticated_providers_includes_active_bare_custom_endpoint(monke assert bare_custom["api_url"] == "https://www.ccsub.net/v1" +def test_list_authenticated_providers_can_probe_active_bare_custom_endpoint(monkeypatch): + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + monkeypatch.setattr( + "hermes_cli.models.fetch_api_models", + lambda api_key, api_url, **kwargs: ["gpt-4o", "gpt-4o-mini"], + ) + + providers = list_authenticated_providers( + current_provider="custom", + current_base_url="https://www.ccsub.net/v1", + current_model="gpt-4o", + user_providers={}, + custom_providers=[], + probe_custom_providers=False, + probe_current_custom_provider=True, + ) + + bare_custom = next(p for p in providers if p["slug"] == "custom") + assert bare_custom["is_current"] is True + assert bare_custom["models"] == ["gpt-4o", "gpt-4o-mini"] + + def test_switch_model_accepts_explicit_bare_custom_current_endpoint(monkeypatch): """Picker selections for bare custom endpoints should route to current base_url.""" monkeypatch.setattr("hermes_cli.models.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index a74c7d2a322c..0933be0213f1 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -5963,6 +5963,7 @@ def test_model_options_does_not_overwrite_curated_models(monkeypatch): # list_authenticated_providers is the single source. assert listing.call_count == 1 assert listing.call_args.kwargs["probe_custom_providers"] is False + assert listing.call_args.kwargs["probe_current_custom_provider"] is True def test_model_options_propagates_list_exception(monkeypatch): @@ -6002,6 +6003,7 @@ def test_model_options_refresh_allows_custom_provider_probes(monkeypatch): assert "result" in resp, resp assert listing.call_args.kwargs["probe_custom_providers"] is True + assert listing.call_args.kwargs["probe_current_custom_provider"] is False class _ImmediateThread: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 1b2156f4c20b..db9826bbb2f3 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -12399,6 +12399,7 @@ def _(rid, params: dict) -> dict: capabilities=True, refresh=bool(params.get("refresh")), probe_custom_providers=bool(params.get("refresh")), + probe_current_custom_provider=not bool(params.get("refresh")), ) return _ok(rid, payload) except Exception as e: From b3bee33ab3dbd45ae93169401e0c0bb088553e2f Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:24:02 -0600 Subject: [PATCH 029/610] fix(tui): keep bare custom model listing stable --- hermes_cli/model_switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 905cb46cd903..5d9ecfd20508 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -2104,7 +2104,7 @@ def list_authenticated_providers( ) ): _models = [current_model] if current_model else [] - if _can_probe_custom_provider(row_is_current=True): + if refresh or probe_current_custom_provider: try: from hermes_cli.models import fetch_api_models From 830165473e0920c2baf8c2a6863976edb0c52943 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:25:26 -0600 Subject: [PATCH 030/610] fix(web): refresh dashboard model picker --- web/src/components/ModelPickerDialog.tsx | 79 ++++++++++++++++++------ web/src/lib/api.test.ts | 48 ++++++++++++++ web/src/lib/api.ts | 17 ++++- 3 files changed, 123 insertions(+), 21 deletions(-) create mode 100644 web/src/lib/api.test.ts diff --git a/web/src/components/ModelPickerDialog.tsx b/web/src/components/ModelPickerDialog.tsx index d9adc1198007..554288e60ecb 100644 --- a/web/src/components/ModelPickerDialog.tsx +++ b/web/src/components/ModelPickerDialog.tsx @@ -6,7 +6,7 @@ import { Input } from "@nous-research/ui/ui/components/input"; import { Label } from "@nous-research/ui/ui/components/label"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import type { GatewayClient } from "@/lib/gatewayClient"; -import { Check, Search, X } from "lucide-react"; +import { Check, RefreshCw, Search, X } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { cn, themedBody } from "@/lib/utils"; @@ -71,7 +71,7 @@ interface Props { onSubmit?(slashCommand: string): void; /** Standalone-mode: when present (and onSubmit absent), picker calls onApply. */ - loader?(): Promise; + loader?(options?: { refresh?: boolean }): Promise; onApply?(args: { confirmExpensiveModel?: boolean; provider: string; @@ -111,37 +111,70 @@ export function ModelPickerDialog(props: Props) { const [query, setQuery] = useState(""); const [persistGlobal, setPersistGlobal] = useState(alwaysGlobal); const [applying, setApplying] = useState(false); + const [refreshing, setRefreshing] = useState(false); const [pendingConfirm, setPendingConfirm] = useState(null); const closedRef = useRef(false); + const applyOptions = (r: ModelOptionsResponse) => { + const next = r?.providers ?? []; + setProviders(next); + setCurrentModel(String(r?.model ?? "")); + setCurrentProviderSlug(String(r?.provider ?? "")); + setSelectedSlug((prev) => { + if (prev && next.some((p) => p.slug === prev)) return prev; + return (next.find((p) => p.is_current) ?? next[0])?.slug ?? ""; + }); + setSelectedModel(""); + }; + + const requestOptions = (refresh = false) => + standalone + ? (loader as (options?: { refresh?: boolean }) => Promise)({ + refresh, + }) + : (gw as GatewayClient).request( + "model.options", + { + ...(sessionId ? { session_id: sessionId } : {}), + ...(refresh ? { refresh: true } : {}), + }, + ); + + const refreshOptions = () => { + setError(null); + setRefreshing(true); + + requestOptions(true) + .then((r) => { + if (closedRef.current) return; + applyOptions(r); + }) + .catch((e) => { + if (closedRef.current) return; + setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (closedRef.current) return; + setRefreshing(false); + }); + }; + // Load providers + models on open. useEffect(() => { closedRef.current = false; - const promise = standalone - ? (loader as () => Promise)() - : (gw as GatewayClient).request( - "model.options", - sessionId ? { session_id: sessionId } : {}, - ); - - promise + requestOptions() .then((r) => { if (closedRef.current) return; - const next = r?.providers ?? []; - setProviders(next); - setCurrentModel(String(r?.model ?? "")); - setCurrentProviderSlug(String(r?.provider ?? "")); - setSelectedSlug( - (next.find((p) => p.is_current) ?? next[0])?.slug ?? "", - ); - setSelectedModel(""); - setLoading(false); + applyOptions(r); }) .catch((e) => { if (closedRef.current) return; setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (closedRef.current) return; setLoading(false); }); @@ -390,6 +423,14 @@ export function ModelPickerDialog(props: Props) { )}
+ diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts new file mode 100644 index 000000000000..eff359518c17 --- /dev/null +++ b/web/src/lib/api.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { api } from "./api"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("api.getModelOptions", () => { + it("requests a live model refresh when asked", async () => { + vi.stubGlobal("window", {}); + + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ providers: [] }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await api.getModelOptions({ refresh: true }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/model/options?refresh=1", + expect.objectContaining({ credentials: "include" }), + ); + }); + + it("keeps explicit profile scoping when refreshing", async () => { + vi.stubGlobal("window", {}); + + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ providers: [] }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await api.getModelOptions({ profile: "default", refresh: true }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/model/options?profile=default&refresh=1", + expect.objectContaining({ credentials: "include" }), + ); + }); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index dff03b155879..1f14ee68b34d 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -475,8 +475,21 @@ export const api = { getDefaults: () => fetchJSON>("/api/config/defaults"), getSchema: () => fetchJSON<{ fields: Record; category_order: string[] }>("/api/config/schema"), getModelInfo: () => fetchJSON("/api/model/info"), - getModelOptions: (profile?: string) => - fetchJSON(`/api/model/options${profileQuery(profile)}`), + getModelOptions: ( + profileOrOptions?: string | { profile?: string; refresh?: boolean }, + ) => { + const profile = + typeof profileOrOptions === "string" + ? profileOrOptions + : profileOrOptions?.profile; + const refresh = + typeof profileOrOptions === "object" && !!profileOrOptions.refresh; + const qs = new URLSearchParams(); + if (profile) qs.set("profile", profile); + if (refresh) qs.set("refresh", "1"); + const suffix = qs.toString() ? `?${qs.toString()}` : ""; + return fetchJSON(`/api/model/options${suffix}`); + }, getAuxiliaryModels: () => fetchJSON("/api/model/auxiliary"), getMoaModels: () => fetchJSON("/api/model/moa"), saveMoaModels: (body: MoaConfigResponse) => From f69e3aadf16f4c3ad172fac0c239072623749160 Mon Sep 17 00:00:00 2001 From: Fan Yang Date: Mon, 6 Jul 2026 13:26:32 -0700 Subject: [PATCH 031/610] fix(auxiliary): refresh auto-routed provider credentials on 401 Infer the concrete auxiliary auth provider from the selected client base URL so provider:auto routes can refresh Copilot/Codex/Anthropic/Nous credentials after auth errors, instead of skipping refresh because resolved_provider stayed 'auto'. Adds the copilot branch to _refresh_provider_credentials and evicts the stale auto-route cache before retrying. Fixes #20832. Salvaged from PR #20837, reapplied surgically onto current main (branch predated the _retry_same_provider_sync/async extraction). --- agent/auxiliary_client.py | 72 ++++++++++++++++++++++++---- tests/agent/test_auxiliary_client.py | 63 ++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 10 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 2a1b5b085c22..bc14c69f5412 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -3388,6 +3388,21 @@ def _refresh_provider_credentials(provider: str) -> bool: """Refresh short-lived credentials for OAuth-backed auxiliary providers.""" normalized = _normalize_aux_provider(provider) try: + if normalized == "copilot": + from hermes_cli.copilot_auth import ( + _jwt_cache, + _token_fingerprint, + exchange_copilot_token, + resolve_copilot_token, + ) + + raw_token, _source = resolve_copilot_token() + if not str(raw_token or "").strip(): + return False + _jwt_cache.pop(_token_fingerprint(raw_token), None) + exchange_copilot_token(raw_token) + _evict_cached_clients(normalized) + return True if normalized == "openai-codex": from hermes_cli.auth import resolve_codex_runtime_credentials @@ -3442,6 +3457,31 @@ def _refresh_provider_credentials(provider: str) -> bool: return False +def _auth_refresh_provider_for_route( + resolved_provider: Optional[str], + client_base_url: str, +) -> str: + """Return the provider whose short-lived credentials should be refreshed. + + Auto-routed auxiliary calls keep ``resolved_provider == "auto"`` even + after _get_cached_client() selects a concrete backend. Infer the backend + from the selected client's base URL so auth refresh works for auto → + Copilot/Codex/Anthropic/Nous routes too. (#20832) + """ + normalized = _normalize_aux_provider(resolved_provider) + if normalized and normalized != "auto": + return normalized + if base_url_host_matches(client_base_url, "api.githubcopilot.com"): + return "copilot" + if base_url_host_matches(client_base_url, "chatgpt.com"): + return "openai-codex" + if base_url_host_matches(client_base_url, "api.anthropic.com"): + return "anthropic" + if base_url_host_matches(client_base_url, "inference-api.nousresearch.com"): + return "nous" + return normalized + + def _try_payment_fallback( failed_provider: str, task: str = None, @@ -6464,18 +6504,24 @@ def call_llm( refreshed_client.chat.completions.create(**kwargs), task) # ── Auth refresh retry ─────────────────────────────────────── + auth_refresh_provider = _auth_refresh_provider_for_route( + resolved_provider, _base_info) if (_is_auth_error(first_err) - and resolved_provider not in {"auto", "", None} + and auth_refresh_provider not in {"auto", "", None} and not client_is_nous): - if _refresh_provider_credentials(resolved_provider): + if _refresh_provider_credentials(auth_refresh_provider): + if auth_refresh_provider != _normalize_aux_provider(resolved_provider): + # The stale client is cached under the route label + # (e.g. "auto"), not the concrete backend we refreshed. + _evict_cached_clients(resolved_provider) logger.info( "Auxiliary %s: refreshed %s credentials after auth error, retrying", - task or "call", resolved_provider, + task or "call", auth_refresh_provider, ) return _retry_same_provider_sync( task=task, - resolved_provider=resolved_provider, - resolved_model=resolved_model, + resolved_provider=auth_refresh_provider, + resolved_model=resolved_model or final_model, resolved_base_url=resolved_base_url, resolved_api_key=resolved_api_key, resolved_api_mode=resolved_api_mode, @@ -6992,18 +7038,24 @@ async def async_call_llm( await refreshed_client.chat.completions.create(**kwargs), task) # ── Auth refresh retry (mirrors sync call_llm) ─────────────── + auth_refresh_provider = _auth_refresh_provider_for_route( + resolved_provider, _client_base) if (_is_auth_error(first_err) - and resolved_provider not in {"auto", "", None} + and auth_refresh_provider not in {"auto", "", None} and not client_is_nous): - if _refresh_provider_credentials(resolved_provider): + if _refresh_provider_credentials(auth_refresh_provider): + if auth_refresh_provider != _normalize_aux_provider(resolved_provider): + # The stale client is cached under the route label + # (e.g. "auto"), not the concrete backend we refreshed. + _evict_cached_clients(resolved_provider) logger.info( "Auxiliary %s (async): refreshed %s credentials after auth error, retrying", - task or "call", resolved_provider, + task or "call", auth_refresh_provider, ) return await _retry_same_provider_async( task=task, - resolved_provider=resolved_provider, - resolved_model=resolved_model, + resolved_provider=auth_refresh_provider, + resolved_model=resolved_model or final_model, resolved_base_url=resolved_base_url, resolved_api_key=resolved_api_key, resolved_api_mode=resolved_api_mode, diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 49c75add205e..59ad0286e232 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -3376,6 +3376,69 @@ class TestAuxiliaryAuthRefreshRetry: assert stale_client.chat.completions.create.call_count == 1 assert fresh_client.chat.completions.create.call_count == 1 + def test_call_llm_refreshes_copilot_when_auto_routes_to_copilot_on_401(self): + stale_client = MagicMock() + stale_client.base_url = "https://api.githubcopilot.com" + stale_client.chat.completions.create.side_effect = _AuxAuth401( + "IDE token expired: unauthorized: token expired" + ) + + fresh_client = MagicMock() + fresh_client.base_url = "https://api.githubcopilot.com" + fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-auto-copilot") + + with ( + patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("auto", None, None, None, None)), + patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "gpt-5.5"), (fresh_client, "gpt-5.5")]) as mock_get_client, + patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh, + patch("agent.auxiliary_client._evict_cached_clients") as mock_evict, + ): + resp = call_llm( + task="title_generation", + messages=[{"role": "user", "content": "hi"}], + main_runtime={"provider": "copilot", "model": "gpt-5.5"}, + ) + + assert resp.choices[0].message.content == "fresh-auto-copilot" + mock_refresh.assert_called_once_with("copilot") + mock_evict.assert_called_once_with("auto") + assert mock_get_client.call_args_list[0].args[0] == "auto" + assert mock_get_client.call_args_list[1].args[0] == "copilot" + assert mock_get_client.call_args_list[1].args[1] == "gpt-5.5" + assert stale_client.chat.completions.create.call_count == 1 + assert fresh_client.chat.completions.create.call_count == 1 + + def test_call_llm_refreshes_codex_when_auto_routes_to_codex_on_401(self): + # Preflight compression's exact failure (#23670): provider auto → + # Codex OAuth backend 401s → before the fix, no refresh was attempted + # because resolved_provider stayed "auto". + stale_client = MagicMock() + stale_client.base_url = "https://chatgpt.com/backend-api/codex" + stale_client.chat.completions.create.side_effect = _AuxAuth401("User not found.") + + fresh_client = MagicMock() + fresh_client.base_url = "https://chatgpt.com/backend-api/codex" + fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-auto-codex") + + with ( + patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("auto", None, None, None, None)), + patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "gpt-5.5"), (fresh_client, "gpt-5.5")]) as mock_get_client, + patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh, + patch("agent.auxiliary_client._evict_cached_clients") as mock_evict, + ): + resp = call_llm( + task="compression", + messages=[{"role": "user", "content": "summarize"}], + main_runtime={"provider": "openai-codex", "model": "gpt-5.5"}, + ) + + assert resp.choices[0].message.content == "fresh-auto-codex" + mock_refresh.assert_called_once_with("openai-codex") + mock_evict.assert_called_once_with("auto") + assert mock_get_client.call_args_list[1].args[0] == "openai-codex" + assert stale_client.chat.completions.create.call_count == 1 + assert fresh_client.chat.completions.create.call_count == 1 + def test_call_llm_refreshes_anthropic_on_401_for_non_vision(self): stale_client = MagicMock() stale_client.base_url = "https://api.anthropic.com" From d42e9b1788d9107962d2f00edb98069ab2ed9bdb Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:26:42 -0700 Subject: [PATCH 032/610] fix(auxiliary): recover from stale fallback-candidate credentials instead of aborting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fallback candidate can itself carry a stale credential (e.g. an expired ANTHROPIC_TOKEN picked up by _try_anthropic). Its 401 previously propagated out of the fallback call site and aborted the auxiliary task — for compression: a 60s cooldown + context marker while the session kept growing past the context cap. Live case: mattalachia debug dump (Jul 2026), Codex timeout → Anthropic 401 x5 → 296K 'Cannot compress further'. Now each fallback candidate call is wrapped: on auth error, refresh the candidate's provider credentials and retry once; if unrefreshable, mark the provider unhealthy and walk the discovery chain again so the next viable candidate serves. Sync + async paths. Non-auth errors still raise unchanged. --- agent/auxiliary_client.py | 183 ++++++++++++++++++++++++--- tests/agent/test_auxiliary_client.py | 130 +++++++++++++++++++ 2 files changed, 296 insertions(+), 17 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index bc14c69f5412..93b7abcef60b 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -3482,6 +3482,127 @@ def _auth_refresh_provider_for_route( return normalized +def _call_fallback_candidate_sync( + fb_client: Any, + fb_model: Optional[str], + fb_label: str, + *, + task: Optional[str], + messages: list, + temperature: Optional[float], + max_tokens: Optional[int], + tools: Optional[list], + effective_timeout: float, + effective_extra_body: dict, +) -> Optional[Any]: + """Call one fallback candidate with stale-credential recovery. + + A fallback candidate can itself carry a stale credential (e.g. an expired + ``ANTHROPIC_TOKEN`` picked up by ``_try_anthropic``). Before this helper, + such a 401 propagated out of the fallback site and aborted the auxiliary + task (for compression: a 60s cooldown + context marker) even though other + healthy candidates remained. Live case: a Codex-timeout → Anthropic + fallback 401-looped five times in one session (mattalachia debug dump, + Jul 2026). + + On an auth error: refresh the candidate's provider credentials and retry + once with a rebuilt client; if the retry also auth-fails (non-refreshable + expired token), mark the provider unhealthy and return ``None`` so the + caller can continue to the next fallback layer. Non-auth errors raise. + """ + fb_base = str(getattr(fb_client, "base_url", "") or "") + fb_kwargs = _build_call_kwargs( + fb_label, fb_model, messages, + temperature=temperature, max_tokens=max_tokens, + tools=tools, timeout=effective_timeout, + extra_body=effective_extra_body, base_url=fb_base) + try: + return _validate_llm_response( + fb_client.chat.completions.create(**fb_kwargs), task) + except Exception as fb_err: + if not _is_auth_error(fb_err): + raise + fb_provider = _auth_refresh_provider_for_route(fb_label, fb_base) + if fb_provider not in {"auto", "", None} and _refresh_provider_credentials(fb_provider): + retry_client, retry_model = _get_cached_client(fb_provider, fb_model) + if retry_client is not None: + retry_kwargs = _build_call_kwargs( + fb_provider, retry_model or fb_model, messages, + temperature=temperature, max_tokens=max_tokens, + tools=tools, timeout=effective_timeout, + extra_body=effective_extra_body, + base_url=str(getattr(retry_client, "base_url", "") or fb_base)) + try: + return _validate_llm_response( + retry_client.chat.completions.create(**retry_kwargs), task) + except Exception as retry_err: + if not _is_auth_error(retry_err): + raise + # Refresh unavailable or the refreshed credential still 401s — + # the token is dead (expired setup token with no refresh token). + # Quarantine the candidate so subsequent chain walks skip it, and + # let the caller move on instead of aborting the whole task. + _mark_provider_unhealthy(fb_provider or fb_label) + logger.warning( + "Auxiliary %s: fallback candidate %s has a stale/unrefreshable " + "credential (%s) — skipping to next fallback", + task or "call", fb_label, fb_err, + ) + return None + + +async def _call_fallback_candidate_async( + fb_client: Any, + fb_model: Optional[str], + fb_label: str, + *, + task: Optional[str], + messages: list, + temperature: Optional[float], + max_tokens: Optional[int], + tools: Optional[list], + effective_timeout: float, + effective_extra_body: dict, +) -> Optional[Any]: + """Async mirror of :func:`_call_fallback_candidate_sync`.""" + fb_base = str(getattr(fb_client, "base_url", "") or "") + fb_kwargs = _build_call_kwargs( + fb_label, fb_model, messages, + temperature=temperature, max_tokens=max_tokens, + tools=tools, timeout=effective_timeout, + extra_body=effective_extra_body, base_url=fb_base) + try: + return _validate_llm_response( + await fb_client.chat.completions.create(**fb_kwargs), task) + except Exception as fb_err: + if not _is_auth_error(fb_err): + raise + fb_provider = _auth_refresh_provider_for_route(fb_label, fb_base) + if fb_provider not in {"auto", "", None} and _refresh_provider_credentials(fb_provider): + retry_client, retry_model = _get_cached_client( + fb_provider, fb_model, async_mode=True) + if retry_client is not None: + retry_kwargs = _build_call_kwargs( + fb_provider, retry_model or fb_model, messages, + temperature=temperature, max_tokens=max_tokens, + tools=tools, timeout=effective_timeout, + extra_body=effective_extra_body, + base_url=str(getattr(retry_client, "base_url", "") or fb_base)) + try: + return _validate_llm_response( + await retry_client.chat.completions.create(**retry_kwargs), task) + except Exception as retry_err: + if not _is_auth_error(retry_err): + raise + _mark_provider_unhealthy(fb_provider or fb_label) + logger.warning( + "Auxiliary %s (async): fallback candidate %s has a stale/unrefreshable " + "credential (%s) — skipping to next fallback", + task or "call", fb_label, fb_err, + ) + return None + + def _try_payment_fallback( failed_provider: str, task: str = None, @@ -6691,14 +6812,28 @@ def call_llm( resolved_provider, task, reason=reason) if fb_client is not None: - fb_kwargs = _build_call_kwargs( - fb_label, fb_model, messages, + fb_resp = _call_fallback_candidate_sync( + fb_client, fb_model, fb_label, + task=task, messages=messages, temperature=temperature, max_tokens=max_tokens, - tools=tools, timeout=effective_timeout, - extra_body=effective_extra_body, - base_url=str(getattr(fb_client, "base_url", "") or "")) - return _validate_llm_response( - fb_client.chat.completions.create(**fb_kwargs), task) + tools=tools, effective_timeout=effective_timeout, + effective_extra_body=effective_extra_body) + if fb_resp is not None: + return fb_resp + # The candidate had a stale/unrefreshable credential and was + # quarantined — walk the discovery chain once more; unhealthy + # entries are skipped so the next viable candidate serves. + fb_client, fb_model, fb_label = _try_payment_fallback( + resolved_provider, task, reason="stale fallback credential") + if fb_client is not None: + fb_resp = _call_fallback_candidate_sync( + fb_client, fb_model, fb_label, + task=task, messages=messages, + temperature=temperature, max_tokens=max_tokens, + tools=tools, effective_timeout=effective_timeout, + effective_extra_body=effective_extra_body) + if fb_resp is not None: + return fb_resp # All fallback layers exhausted — emit a single user-visible # warning so the operator knows aux task is about to fail. # (#26882) The error itself is re-raised below. @@ -7182,20 +7317,34 @@ async def async_call_llm( resolved_provider, task, reason=reason) if fb_client is not None: - fb_kwargs = _build_call_kwargs( - fb_label, fb_model, messages, - temperature=temperature, max_tokens=max_tokens, - tools=tools, timeout=effective_timeout, - extra_body=effective_extra_body, - base_url=str(getattr(fb_client, "base_url", "") or "")) # Convert sync fallback client to async async_fb, async_fb_model = _to_async_client( fb_client, fb_model or "", is_vision=(task == "vision") ) - if async_fb_model and async_fb_model != fb_kwargs.get("model"): - fb_kwargs["model"] = async_fb_model - return _validate_llm_response( - await async_fb.chat.completions.create(**fb_kwargs), task) + fb_resp = await _call_fallback_candidate_async( + async_fb, async_fb_model or fb_model, fb_label, + task=task, messages=messages, + temperature=temperature, max_tokens=max_tokens, + tools=tools, effective_timeout=effective_timeout, + effective_extra_body=effective_extra_body) + if fb_resp is not None: + return fb_resp + # Stale/unrefreshable candidate credential — quarantined; walk + # the discovery chain once more (unhealthy entries skipped). + fb_client, fb_model, fb_label = _try_payment_fallback( + resolved_provider, task, reason="stale fallback credential") + if fb_client is not None: + async_fb, async_fb_model = _to_async_client( + fb_client, fb_model or "", is_vision=(task == "vision") + ) + fb_resp = await _call_fallback_candidate_async( + async_fb, async_fb_model or fb_model, fb_label, + task=task, messages=messages, + temperature=temperature, max_tokens=max_tokens, + tools=tools, effective_timeout=effective_timeout, + effective_extra_body=effective_extra_body) + if fb_resp is not None: + return fb_resp # All fallback layers exhausted — warn before re-raising. (#26882) logger.warning( "Auxiliary %s (async): %s on %s and all fallbacks exhausted " diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 59ad0286e232..5e18fa73a1f8 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -2159,6 +2159,136 @@ class TestCallLlmPaymentFallback: mock_fb.assert_not_called() +class TestStaleFallbackCandidateSkip: + """A fallback candidate with a stale credential must not abort the task. + + Live case (mattalachia debug dump, Jul 2026): Codex compression timed out, + the aux chain fell back to Anthropic using an expired ANTHROPIC_TOKEN, and + the resulting 401 aborted compression with a 60s cooldown — five times in + one session — even though refreshing or skipping the candidate would have + let compression proceed. + """ + + def _timeout_err(self): + # Class name carries "Timeout" — matches _is_connection_error's + # type-name detection, like the real Codex stream-deadline error. + class _AuxStreamTimeoutError(Exception): + pass + return _AuxStreamTimeoutError( + "Codex auxiliary Responses stream exceeded 120.0s total timeout") + + def test_stale_anthropic_fallback_refreshes_and_retries(self, monkeypatch): + """401 from the fallback candidate → refresh its creds → retry succeeds.""" + primary_client = MagicMock() + primary_client.base_url = "https://chatgpt.com/backend-api/codex" + primary_client.chat.completions.create.side_effect = self._timeout_err() + + stale_fb = MagicMock() + stale_fb.base_url = "https://api.anthropic.com" + stale_fb.chat.completions.create.side_effect = _AuxAuth401("Invalid bearer token") + + fresh_fb = MagicMock() + fresh_fb.base_url = "https://api.anthropic.com" + fresh_fb.chat.completions.create.return_value = _DummyResponse("fresh-fallback") + + def _cached_client(provider, model=None, **kw): + if provider == "anthropic": + return (fresh_fb, "claude-haiku-4-5-20251001") + return (primary_client, "gpt-5.5") + + with patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("auto", None, None, None, None)), \ + patch("agent.auxiliary_client._get_cached_client", side_effect=_cached_client), \ + patch("agent.auxiliary_client._try_configured_fallback_chain", + return_value=(None, None, "")), \ + patch("agent.auxiliary_client._try_main_fallback_chain", + return_value=(None, None, "")), \ + patch("agent.auxiliary_client._try_payment_fallback", + return_value=(stale_fb, "claude-haiku-4-5-20251001", "anthropic")), \ + patch("agent.auxiliary_client._refresh_provider_credentials", + return_value=True) as mock_refresh: + result = call_llm( + task="compression", + messages=[{"role": "user", "content": "summarize"}], + ) + + assert result.choices[0].message.content == "fresh-fallback" + mock_refresh.assert_called_once_with("anthropic") + assert stale_fb.chat.completions.create.call_count == 1 + assert fresh_fb.chat.completions.create.call_count == 1 + + def test_unrefreshable_stale_candidate_is_skipped_to_next(self, monkeypatch): + """Refresh fails (expired setup token) → candidate quarantined, chain + walked again, next candidate serves the request.""" + primary_client = MagicMock() + primary_client.base_url = "https://chatgpt.com/backend-api/codex" + primary_client.chat.completions.create.side_effect = self._timeout_err() + + stale_fb = MagicMock() + stale_fb.base_url = "https://api.anthropic.com" + stale_fb.chat.completions.create.side_effect = _AuxAuth401("Invalid bearer token") + + healthy_fb = MagicMock() + healthy_fb.base_url = "https://openrouter.ai/api/v1" + healthy_fb.chat.completions.create.return_value = _DummyResponse("openrouter-serves") + + fb_walks = [ + (stale_fb, "claude-haiku-4-5-20251001", "anthropic"), + (healthy_fb, "fallback-model", "openrouter"), + ] + + with patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("auto", None, None, None, None)), \ + patch("agent.auxiliary_client._get_cached_client", + return_value=(primary_client, "gpt-5.5")), \ + patch("agent.auxiliary_client._try_configured_fallback_chain", + return_value=(None, None, "")), \ + patch("agent.auxiliary_client._try_main_fallback_chain", + return_value=(None, None, "")), \ + patch("agent.auxiliary_client._try_payment_fallback", + side_effect=fb_walks) as mock_fb, \ + patch("agent.auxiliary_client._refresh_provider_credentials", + return_value=False), \ + patch("agent.auxiliary_client._mark_provider_unhealthy") as mock_mark: + result = call_llm( + task="compression", + messages=[{"role": "user", "content": "summarize"}], + ) + + assert result.choices[0].message.content == "openrouter-serves" + assert mock_fb.call_count == 2 + assert mock_fb.call_args_list[1].kwargs.get("reason") == "stale fallback credential" + mock_mark.assert_called_once_with("anthropic") + assert stale_fb.chat.completions.create.call_count == 1 + assert healthy_fb.chat.completions.create.call_count == 1 + + def test_non_auth_fallback_error_still_raises(self, monkeypatch): + """A non-auth error from the fallback candidate propagates unchanged.""" + primary_client = MagicMock() + primary_client.base_url = "https://chatgpt.com/backend-api/codex" + primary_client.chat.completions.create.side_effect = self._timeout_err() + + broken_fb = MagicMock() + broken_fb.base_url = "https://api.anthropic.com" + broken_fb.chat.completions.create.side_effect = ValueError("malformed response") + + with patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("auto", None, None, None, None)), \ + patch("agent.auxiliary_client._get_cached_client", + return_value=(primary_client, "gpt-5.5")), \ + patch("agent.auxiliary_client._try_configured_fallback_chain", + return_value=(None, None, "")), \ + patch("agent.auxiliary_client._try_main_fallback_chain", + return_value=(None, None, "")), \ + patch("agent.auxiliary_client._try_payment_fallback", + return_value=(broken_fb, "claude-haiku-4-5-20251001", "anthropic")): + with pytest.raises(ValueError, match="malformed response"): + call_llm( + task="compression", + messages=[{"role": "user", "content": "summarize"}], + ) + + class TestAuxiliaryFallbackLayering: """Explicit-provider users get layered fallback: configured_chain → main agent → warn.""" From 8f80a982a67c601a800b3695ee4ef90c5d02f246 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:27:41 -0700 Subject: [PATCH 033/610] chore: add fanyangCS to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index f6d3c4271a72..98fc9dfe28bd 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -284,6 +284,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "fanyang@microsoft.com": "fanyangCS", "bigstar0920@gmail.com": "bigstar0920", "hello@tanmaychoudhary.com": "tanmayxchoudhary", "285906080+AIalliAI@users.noreply.github.com": "AIalliAI", From 91bcfff479c70427b95dcd908d958bb28b25c21f Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:13:13 -0700 Subject: [PATCH 034/610] Revert "docs(skills): tighten dynamic-workflow per donovan-yohan review" This reverts commit 4f008b6412588a54a0e7569bdb9dd07cc537378b. --- .../dynamic-workflow/SKILL.md | 44 ++++++------------- 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/skills/autonomous-ai-agents/dynamic-workflow/SKILL.md b/skills/autonomous-ai-agents/dynamic-workflow/SKILL.md index 2844c00cf5ae..0a35fc106b69 100644 --- a/skills/autonomous-ai-agents/dynamic-workflow/SKILL.md +++ b/skills/autonomous-ai-agents/dynamic-workflow/SKILL.md @@ -47,7 +47,7 @@ the split is enforced by a real capability boundary, not a style preference: |---|---|---| | Use for | DETERMINISTIC fan-out — fetch N URLs, parse N files, run N shell commands, template N outputs | LLM-JUDGMENT fan-out — classify, review, decide, write, refute, audit per item | | The script holds | the loop + branching + intermediate vars (real Python) | n/a — you call it once with a `tasks=[...]` array; each task is its own isolated agent | -| Tools available inside | `web_search, web_extract, read_file, write_file, search_files, terminal, patch` ONLY (the `SANDBOX_ALLOWED_TOOLS` set) | configured child toolsets, subject to delegate restrictions (leaf children are stripped of `delegate_task`, `clarify`, `memory`, `send_message`, `execute_code` — see `DELEGATE_BLOCKED_TOOLS`) | +| Tools available inside | `web_search, web_extract, read_file, write_file, search_files, terminal, patch` ONLY (the `SANDBOX_ALLOWED_TOOLS` set) | full toolsets per child (configurable) | | Can it call `delegate_task`? | **NO.** `delegate_task` is NOT in `SANDBOX_ALLOWED_TOOLS`. Do not write a script that imports it — it will fail. | itself, if `role='orchestrator'` and `max_spawn_depth>=2` | | Concurrency | you control it in Python (`ThreadPoolExecutor`, batches) | `delegation.max_concurrent_children` (default 3; raise in config.yaml) | | Cost shape | cheap — most steps are tool calls, no per-item LLM unless you call `web_search`/aux | one model call tree PER child task — multiplies linearly, can be very expensive | @@ -81,11 +81,7 @@ So a "workflow" in Hermes is one of: path would time out or when the user must be able to walk away. Never promise "background, resumable, hundreds of agents over days" from a plain -`delegate_task` call. For a durable multi-agent workflow *graph*, the kanban -swarm is the right fit. For simpler durable/out-of-turn cases there are lighter -options too: a `cronjob` one-shot or scheduled job, or a managed -`terminal(background=True, notify_on_complete=True)` process — both survive the -turn without standing up a full task graph. +`delegate_task` call. That is the kanban path or nothing. ## Workflow recipe (foreground) @@ -94,28 +90,19 @@ turn without standing up a full task graph. (else it's serial, not fan-out — see when_not_to_use). 2. **Deterministic pre-pass (Layer A).** In one `execute_code` script, gather the manifest: list the files, extract the candidate sites, fetch the raw sources, - compute anything regex/parse can compute. Write a manifest to a **unique - per-run** directory — `/tmp/wf__/manifest.jsonl` (one unit per - line), never a bare `/tmp/wf_/` that a prior interrupted run could have - left stale outputs in. This is the "plan in code." Print the unit count and - the run dir, and stop. + compute anything regex/parse can compute. Write a manifest to + `/tmp/wf_/manifest.jsonl` (one unit per line). This is the "plan in + code." Print a count and stop. 3. **Size the fan-out** against `delegate-task-output-patterns`: chunk so each child handles ~8-12 mechanical file edits OR ~2000-3000 lines of reading OR - ~50-70KB of corpus. Look at the LARGEST unit, not the average. One - `delegate_task(tasks=[...])` call is bounded by - `delegation.max_concurrent_children` (default 3) — it does NOT queue hundreds - of tasks internally. For larger fan-out, issue bounded waves yourself (loop: - one batch, collect, next batch) or have the user raise the config - intentionally. + ~50-70KB of corpus. Look at the LARGEST unit, not the average. 4. **LLM-judgment fan-out (Layer B).** Issue ONE `delegate_task` with a `tasks=[]` array, one task per chunk. Each task: reads its slice from the manifest, - emits delimiter-separated lines to `/tmp/wf__/out_.csv`, prints a + emits delimiter-separated lines to `/tmp/wf_/out_.csv`, prints a status word, stops. Do NOT depend on the `summary` field for content. -5. **Synthesize on the parent.** Read the out_*.csv files yourself — verify the - file count and freshness (each was written this run) so a stale or missing - output from an interrupted child isn't silently read as success — then merge - and present. The cross-cutting "whole picture" step stays on the parent — only - the per-unit work fanned out. +5. **Synthesize on the parent.** Read the out_*.csv files yourself, merge, + present. The cross-cutting "whole picture" step stays on the parent — only the + per-unit work fanned out. ## The novel mechanic worth building: adversarial convergence @@ -168,14 +155,9 @@ inherent, not a bug. Two real multipliers: - **Each Layer-B child is a full agent tree.** 20 children ≈ 20× the model calls. `delegation.max_concurrent_children` only bounds *concurrency*, not *total*. - **Hermes aux/subagent model defaults to main-model-first.** Children inherit - the parent's (often expensive reasoning) model. `delegate_task` does NOT expose - a per-task `model` or `profile` field — its per-task keys are - `{goal, context, toolsets, role}`. To run the fan-out cheaper you either route - delegation globally via `delegation` config (model/provider applied to all - children), or — for genuinely model/profile-scoped work — use cron, the kanban - swarm, or a separate Hermes process. The cleanest lever for mechanical fan-out - is still Layer A: do the deterministic part in a script with no per-item LLM at - all. + the parent's (often expensive reasoning) model unless you pin a cheaper one. + For mechanical fan-out, pass a cheaper model per task if the config supports it, + or do the deterministic part in Layer A (no per-item LLM at all). Always: start on a SCOPED slice (one directory, 20 records, 10 endpoints), prove the recipe end-to-end, report the token cost, THEN offer to run it at full scale. From 05cbddc01234ea120cccc1f62d36f1ef352b0d52 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:13:13 -0700 Subject: [PATCH 035/610] Revert "feat(skills): add dynamic-workflow orchestration skill" This reverts commit 5e5191b9faeaf2ea6aac64fa5fe6d753fc95e0f0. --- .../dynamic-workflow/SKILL.md | 191 ------------------ 1 file changed, 191 deletions(-) delete mode 100644 skills/autonomous-ai-agents/dynamic-workflow/SKILL.md diff --git a/skills/autonomous-ai-agents/dynamic-workflow/SKILL.md b/skills/autonomous-ai-agents/dynamic-workflow/SKILL.md deleted file mode 100644 index 0a35fc106b69..000000000000 --- a/skills/autonomous-ai-agents/dynamic-workflow/SKILL.md +++ /dev/null @@ -1,191 +0,0 @@ ---- -name: dynamic-workflow -description: Orchestrate large fan-out work as a plan-in-code "workflow" so the agent's context holds only the final verified answer, not the exhaust of hundreds of intermediate steps. Use for codebase-wide sweeps, large migrations, multi-angle research, and any task too big for one context window where the split strategy is known enough to script. Includes the adversarial-convergence verification recipe (independent attempts + refuters, keep only surviving claims). -version: 1.0.0 -author: Hermes Agent + Teknium -license: MIT -metadata: - hermes: - tags: [orchestration, fan-out, subagents, delegation, verification, migration, audit, research] - category: autonomous-ai-agents - related_skills: [] -when_to_use: - - A task is too big for one context window AND you can describe the split (per-file, per-endpoint, per-source, per-record) - - You want orchestration codified as a re-runnable script, not improvised turn-by-turn - - Quality matters more than token economy: you want independent attempts cross-checked / refuted before you trust the answer - - Codebase-wide bug/security sweep, 100+ file migration, multi-angle research with sources cross-checked -when_not_to_use: - - Small bounded task (<~10 units) — just call the tool directly or do it inline - - Tight serial dependency (B needs A's output) — orchestration overhead is wasted - - You need it to survive the user sending a new message — see "The synchronous trap" below; use cron/kanban instead ---- - -# Dynamic Workflow — plan-in-code fan-out with verification - -This is Hermes's answer to Claude Code's "dynamic workflows" (run hundreds of -parallel subagents in one session). The mechanic worth copying is NOT "more -subagents" — it is **moving the plan, the loop, and the intermediate results -OUT of the context window and INTO a script.** Normally the agent IS the -orchestrator: every intermediate result piles into context, which is exactly -what caps you at a handful of agents. A workflow keeps only the *final verified -answer* in context; the script holds everything else. - -> This skill is self-contained, but it builds on standard fan-out hygiene — -> chunk inputs to ~50-70KB per child, route structured output to files (not the -> `summary` field, which truncates under load), use delimiter-separated lines -> over JSON wrappers, and remember that a "stalled" child often completed its -> write anyway (check the filesystem before retrying). If your install has a -> `delegate-task-output-patterns` skill, load it for the detailed thresholds; -> the rules above are the load-bearing subset. - -## The two orchestration-script layers (pick the right one — they are NOT interchangeable) - -Hermes has no JS runtime. The "orchestration script" is one of two layers, and -the split is enforced by a real capability boundary, not a style preference: - -| | Layer A: `execute_code` (Python script) | Layer B: `delegate_task` batch | -|---|---|---| -| Use for | DETERMINISTIC fan-out — fetch N URLs, parse N files, run N shell commands, template N outputs | LLM-JUDGMENT fan-out — classify, review, decide, write, refute, audit per item | -| The script holds | the loop + branching + intermediate vars (real Python) | n/a — you call it once with a `tasks=[...]` array; each task is its own isolated agent | -| Tools available inside | `web_search, web_extract, read_file, write_file, search_files, terminal, patch` ONLY (the `SANDBOX_ALLOWED_TOOLS` set) | full toolsets per child (configurable) | -| Can it call `delegate_task`? | **NO.** `delegate_task` is NOT in `SANDBOX_ALLOWED_TOOLS`. Do not write a script that imports it — it will fail. | itself, if `role='orchestrator'` and `max_spawn_depth>=2` | -| Concurrency | you control it in Python (`ThreadPoolExecutor`, batches) | `delegation.max_concurrent_children` (default 3; raise in config.yaml) | -| Cost shape | cheap — most steps are tool calls, no per-item LLM unless you call `web_search`/aux | one model call tree PER child task — multiplies linearly, can be very expensive | - -**Rule of thumb:** do the deterministic part in Layer A first (inline, in a -script), then fan out ONLY the irreducibly-LLM step via Layer B. This is -Pattern 1 from `delegate-task-output-patterns`, applied at workflow scale. -Mixing them: a Layer-A script can write a manifest file, and you (the parent) -then read that manifest and issue a single Layer-B `delegate_task` batch. - -## The synchronous trap (READ THIS — it is the #1 way a "workflow" disappoints) - -`delegate_task` runs **synchronously inside the parent turn**. If the user sends -a new message, hits /stop, or /new, every in-flight child is **cancelled and its -work discarded** (status `interrupted`). It does NOT run in the background, and -it does NOT survive the turn. There is no cache-resume of a half-finished fan-out. - -So a "workflow" in Hermes is one of: - -1. **Foreground workflow (default):** Layer A and/or one Layer-B batch, completed - within a single turn. Good for minutes-long fan-out (dozens of units). The - user waits. This is what you build 90% of the time. -2. **Durable workflow (hours/days, survives interruption):** use the **kanban - swarm** (the SQLite-backed multi-agent kernel that ships with Hermes — - `hermes_cli/kanban_swarm.py` + the kanban plugin; if your install has a - `kanban-multiagent` skill, load it for the workflow). It - writes a task graph (root → parallel workers → verifier → synthesizer) into - the SQLite kanban kernel with a JSON blackboard. State persists across turns - and restarts. This is the ONLY path that matches Claude Code's "runs into - hours and days, resumes where it left off." Reach for it when the foreground - path would time out or when the user must be able to walk away. - -Never promise "background, resumable, hundreds of agents over days" from a plain -`delegate_task` call. That is the kanban path or nothing. - -## Workflow recipe (foreground) - -1. **Decompose into independent units.** What is the unit — a file? an endpoint? - a source? a record? Each unit must be answerable WITHOUT the others' output - (else it's serial, not fan-out — see when_not_to_use). -2. **Deterministic pre-pass (Layer A).** In one `execute_code` script, gather the - manifest: list the files, extract the candidate sites, fetch the raw sources, - compute anything regex/parse can compute. Write a manifest to - `/tmp/wf_/manifest.jsonl` (one unit per line). This is the "plan in - code." Print a count and stop. -3. **Size the fan-out** against `delegate-task-output-patterns`: chunk so each - child handles ~8-12 mechanical file edits OR ~2000-3000 lines of reading OR - ~50-70KB of corpus. Look at the LARGEST unit, not the average. -4. **LLM-judgment fan-out (Layer B).** Issue ONE `delegate_task` with a `tasks=[]` - array, one task per chunk. Each task: reads its slice from the manifest, - emits delimiter-separated lines to `/tmp/wf_/out_.csv`, prints a - status word, stops. Do NOT depend on the `summary` field for content. -5. **Synthesize on the parent.** Read the out_*.csv files yourself, merge, - present. The cross-cutting "whole picture" step stays on the parent — only the - per-unit work fanned out. - -## The novel mechanic worth building: adversarial convergence - -This is the part Hermes did NOT already have and the real reason to bother. -Claude Code's quality claim ("independent agents try to refute each other's -findings; only surviving claims surface; iterate until they converge") maps -cleanly onto `delegate_task` batch mode: - -### Recipe: N independent attempts + M refuters - -For a finding-quality task (security audit, "is this code path actually -vulnerable?", "does this migration preserve behavior?", a high-stakes plan): - -1. **Independent attempts (round 1).** Fan out the SAME question to N children - (N=2-4) with DIFFERENT framings/angles in each `context`, so they don't - collapse to the same reasoning. Each writes its claims to - `/tmp/wf_/attempt_.md` as a list of discrete, individually-checkable - claims (one claim per line — atomicity is what makes refutation possible). -2. **Collect + dedupe (parent or Layer A).** Merge all claims into a single - numbered list. Identical claims from independent attempts = higher prior; - note the agreement count per claim. -3. **Refutation round (round 2).** Fan out a refuter batch: each refuter gets the - claim list and is told "your job is to BREAK these claims — for each, find the - counter-evidence (the auth check that DOES exist, the test that DOES cover it, - the edge case the claim ignores). Output `claim_idx|survives|counter_evidence`." - Give refuters the codebase/sources, not the original attempts' reasoning. -4. **Keep only survivors.** A claim surfaces to the user only if it survived - refutation (no refuter produced valid counter-evidence). Filtered claims are - dropped, with a one-line note of why if the user asked for completeness. -5. **Converge (optional).** If round 2 surfaced NEW claims (refuters often find - adjacent issues), feed them back through one more refutation round. Stop when - a round produces no new surviving claims — that's convergence. Cap at 3 rounds - to bound cost. - -This gives you the "more trustworthy than a single pass" property without a -runtime — it's just two `delegate_task` batches and a merge, structured so -disagreement is visible and unsupported claims die before they reach the user. - -### Why atomic claims matter -A refuter cannot break "the auth layer has problems." It CAN break "endpoint -`POST /api/users/:id/role` in src/routes/users.ts:142 has no role check." Force -attempts to emit specific, located, individually-falsifiable claims or the -refutation round is theater. - -## Cost discipline (this is the thing that bites) - -A workflow can consume dramatically more tokens than a normal turn — that is -inherent, not a bug. Two real multipliers: - -- **Each Layer-B child is a full agent tree.** 20 children ≈ 20× the model calls. - `delegation.max_concurrent_children` only bounds *concurrency*, not *total*. -- **Hermes aux/subagent model defaults to main-model-first.** Children inherit - the parent's (often expensive reasoning) model unless you pin a cheaper one. - For mechanical fan-out, pass a cheaper model per task if the config supports it, - or do the deterministic part in Layer A (no per-item LLM at all). - -Always: start on a SCOPED slice (one directory, 20 records, 10 endpoints), prove -the recipe end-to-end, report the token cost, THEN offer to run it at full scale. -Never silently fan out hundreds of children — surface the cost first and let the -user say go. - -## Pitfalls - -- **Writing `delegate_task` inside an `execute_code` script.** It's not in - `SANDBOX_ALLOWED_TOOLS`; the import/stub won't exist. Layer A is deterministic - tools only. Fan out LLM judgment from the parent turn, not from inside a script. -- **Promising background/resumable from `delegate_task`.** It's synchronous and - turn-scoped. Durable = kanban swarm. -- **Trusting `summary` fields for content.** Route structured output to files - (Pattern 2 in delegate-task-output-patterns). -- **Non-atomic claims in the verify recipe.** Unfalsifiable claims survive - refutation by default and pollute the output. Force located, specific claims. -- **Same framing in all "independent" attempts.** They collapse to one answer and - the cross-check is worthless. Vary the angle in each child's context. -- **Fanning out a serial task.** If unit B needs unit A's output, parallelism - produces wrong/empty results. Re-check independence before fanning out. - -## Verification before you call it done - -- Did the deterministic pre-pass actually run, and does the manifest line-count - match the expected unit count? (`wc -l /tmp/wf_/manifest.jsonl`) -- Did every fan-out child write its output file? (`ls /tmp/wf_/out_*.csv`) — - remember stalled children often completed anyway (Pattern 6). -- For the verify recipe: can you point to the refuter counter-evidence for every - DROPPED claim, and confirm every SURFACED claim went through refutation? -- Did you report token cost on the scoped run before offering full scale? From 1c702aa73edd33a8ce19bacc59160b6a705a43b1 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Mon, 6 Jul 2026 16:25:21 +0700 Subject: [PATCH 036/610] fix(agent): run Z.AI overload adaptive backoff on the overloaded path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Z.AI Coding Plan GLM-5.2 reports server overload as HTTP 429 code 1305 ("temporarily overloaded"). classify_api_error routes that to FailoverReason.overloaded (so a valid credential pool isn't burned), but the adaptive Z.AI backoff was gated on is_rate_limited — which excludes overloaded — so it never ran (policy=default) and the request failed after a few quick short retries. Two compounding causes, both fixed here: 1. Detect the Z.AI overload 429 directly and let its adaptive backoff run on the overloaded path, not only the rate_limit path. 2. Raise the retry ceiling for this narrow case via zai_coding_overload_retry_ceiling(). The long-backoff tier (30/60/90/120s) starts after short_attempts (3) retries, but the default api_max_retries is also 3, so the loop always gave up before the long tier could run — leaving the whole long-backoff schedule as dead code. Scope is limited to the existing narrow is_zai_coding_overload_error match, so other providers' 429/503/529 handling is unchanged. --- agent/conversation_loop.py | 29 +++++++++++++++++++++++++---- agent/retry_utils.py | 17 +++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 761c8c0f79e6..122cf76883d3 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -58,7 +58,12 @@ from agent.model_metadata import ( ) from agent.process_bootstrap import _install_safe_stdio from agent.prompt_caching import apply_anthropic_cache_control -from agent.retry_utils import adaptive_rate_limit_backoff, jittered_backoff +from agent.retry_utils import ( + adaptive_rate_limit_backoff, + is_zai_coding_overload_error, + jittered_backoff, + zai_coding_overload_retry_ceiling, +) from agent.trajectory import has_incomplete_scratchpad from agent.usage_pricing import estimate_usage_cost, normalize_usage from hermes_constants import PARTIAL_STREAM_STUB_ID @@ -3142,6 +3147,21 @@ def run_conversation( FailoverReason.timeout, FailoverReason.overloaded, } + # Z.AI Coding Plan GLM-5.2 signals server overload as HTTP 429 + # code 1305 ("temporarily overloaded"). classify_api_error routes + # that to `overloaded` (not `rate_limit`) so the credential pool + # isn't burned on a valid key — but `overloaded` is excluded from + # `is_rate_limited`, which is exactly what gates the adaptive Z.AI + # backoff below. Detect the overload 429 directly so its adaptive + # long-backoff schedule still runs, and raise the retry ceiling so + # the long tier (30/60/90/120s) is actually reachable: the default + # ceiling equals the short-retry threshold, so the loop otherwise + # gives up after a few quick retries and the long tier is dead code. + _is_zai_coding_overload = is_zai_coding_overload_error( + base_url=str(_base), model=_model, error=api_error + ) + if _is_zai_coding_overload: + max_retries = max(max_retries, zai_coding_overload_retry_ceiling()) _should_fallback = ( is_rate_limited or (_is_transport_failure and retry_count >= 2) @@ -4092,7 +4112,7 @@ def run_conversation( pass wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0) _backoff_policy = None - if is_rate_limited and not _retry_after: + if (is_rate_limited or _is_zai_coding_overload) and not _retry_after: wait_time, _backoff_policy = adaptive_rate_limit_backoff( retry_count, base_url=str(_base), @@ -4100,13 +4120,14 @@ def run_conversation( error=api_error, default_wait=wait_time, ) - if is_rate_limited: + if is_rate_limited or _is_zai_coding_overload: _policy_note = "" if _backoff_policy == "zai_coding_overload_long": _policy_note = " (Z.AI Coding overload adaptive long backoff)" elif _backoff_policy == "zai_coding_overload_short": _policy_note = " (Z.AI Coding overload short retry)" - _rate_limit_status = f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries}){_policy_note}..." + _wait_reason = "Provider overloaded" if _is_zai_coding_overload and not is_rate_limited else "Rate limited" + _rate_limit_status = f"⏱️ {_wait_reason}. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries}){_policy_note}..." # Normal retries are buffered to avoid noisy transient chatter. Long # Z.AI Coding waits are different: they can last minutes, so surface # progress immediately instead of making the TUI look frozen. diff --git a/agent/retry_utils.py b/agent/retry_utils.py index 2922156847b6..3fc5645edb6f 100644 --- a/agent/retry_utils.py +++ b/agent/retry_utils.py @@ -127,3 +127,20 @@ def adaptive_rate_limit_backoff( # A smaller jitter ratio keeps long waits readable while still avoiding # synchronized retry storms across concurrent Hermes sessions. return jittered_backoff(1, base_delay=base_delay, max_delay=base_delay, jitter_ratio=0.2), "zai_coding_overload_long" + + +def zai_coding_overload_retry_ceiling(short_attempts: int = 3) -> int: + """Retry-loop ceiling needed for the full Z.AI overload backoff schedule. + + The adaptive policy runs ``short_attempts`` short retries, then walks the + long-backoff table one entry per subsequent attempt. The retry loop gives + up as soon as ``retry_count >= ceiling`` — and that check runs *before* the + attempt's backoff is computed — so the ceiling must sit one past the final + long-backoff entry for every long tier to actually execute. + + With the default ``api_max_retries`` (3) equal to ``short_attempts`` (3), + the loop always gave up before reaching the long tier, leaving the whole + long-backoff schedule as dead code. Callers extend the ceiling to this + value for Z.AI Coding overload 429s so the 30/60/90/120s waits run. + """ + return short_attempts + len(_ZAI_CODING_OVERLOAD_LONG_BACKOFF) + 1 From ba03c5ab275179d642ff945a4aabd892386d3c9b Mon Sep 17 00:00:00 2001 From: xxxigm Date: Mon, 6 Jul 2026 16:26:27 +0700 Subject: [PATCH 037/610] test(retry): cover Z.AI overload retry ceiling reachability Assert the invariant that the Z.AI overload retry ceiling exceeds the short-retry threshold (the original bug had them equal, so the long tier was dead code), and walk the attempt range the retry loop actually traverses to prove the full 30/60/90/120s long-backoff schedule now runs. --- tests/test_retry_utils.py | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_retry_utils.py b/tests/test_retry_utils.py index ff08d3a40625..460a9b8c0f33 100644 --- a/tests/test_retry_utils.py +++ b/tests/test_retry_utils.py @@ -210,3 +210,48 @@ def test_non_zai_backoff_returns_default_wait(): ) assert wait == 12.0 assert policy is None + + +def test_zai_overload_retry_ceiling_exceeds_short_attempts(): + """Invariant: the ceiling must sit above the short-retry threshold, or the + long-backoff tier is unreachable and the whole schedule is dead code + (the original bug: default api_max_retries == short_attempts == 3).""" + from agent.retry_utils import ( + zai_coding_overload_retry_ceiling, + _ZAI_CODING_OVERLOAD_LONG_BACKOFF, + ) + + short_attempts = 3 + ceiling = zai_coding_overload_retry_ceiling(short_attempts) + assert ceiling > short_attempts + # Room for every long-backoff entry to run before giving up: the loop's + # give-up check (retry_count >= ceiling) runs before the attempt's backoff, + # so the ceiling sits one past the final long entry. + assert ceiling == short_attempts + len(_ZAI_CODING_OVERLOAD_LONG_BACKOFF) + 1 + + +def test_zai_overload_ceiling_makes_long_tier_reachable(monkeypatch): + """End-to-end over the attempt range the retry loop actually walks: with the + extended ceiling, at least one attempt reaches the long-backoff tier and the + full 30/60/90/120s schedule is exercised.""" + monkeypatch.setattr(retry_utils, "jittered_backoff", lambda *a, **kw: kw["base_delay"]) + from agent.retry_utils import zai_coding_overload_retry_ceiling + + err = _zai_overload_error() + ceiling = zai_coding_overload_retry_ceiling() + + long_waits = [] + # The loop computes backoff for attempts 1..ceiling-1 (it gives up at ceiling). + for attempt in range(1, ceiling): + _wait, policy = adaptive_rate_limit_backoff( + attempt, + base_url="https://api.z.ai/api/coding/paas/v4", + model="glm-5.2", + error=err, + default_wait=1.0, + ) + if policy == "zai_coding_overload_long": + long_waits.append(_wait) + + assert long_waits, "long-backoff tier never reached within the retry ceiling" + assert long_waits == [30.0, 60.0, 90.0, 120.0] From 45f5a6e659ec8bb17b963ab0775043bf6a9bf7ba Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:49:34 +0530 Subject: [PATCH 038/610] refactor(retry): single-source Z.AI overload short-attempts + drop change-detector assert Follow-up on the salvage of #59523. Two low-risk cleanups surfaced by review: - Extract _ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS as a module constant so adaptive_rate_limit_backoff() and zai_coding_overload_retry_ceiling() share one source of truth. Previously both hardcoded short_attempts=3 independently; tuning one without the other would silently desync the retry ceiling from the backoff schedule. - Replace the tautological formula-mirroring assert in test_zai_overload_retry_ceiling_exceeds_short_attempts with a behavior invariant (ceiling leaves headroom for every long-backoff entry), per the repo's contracts-over-snapshots testing rule. --- agent/conversation_loop.py | 17 +++++++---------- agent/retry_utils.py | 12 ++++++++++-- tests/test_retry_utils.py | 11 +++++++---- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 122cf76883d3..cbcb85617013 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -3147,16 +3147,13 @@ def run_conversation( FailoverReason.timeout, FailoverReason.overloaded, } - # Z.AI Coding Plan GLM-5.2 signals server overload as HTTP 429 - # code 1305 ("temporarily overloaded"). classify_api_error routes - # that to `overloaded` (not `rate_limit`) so the credential pool - # isn't burned on a valid key — but `overloaded` is excluded from - # `is_rate_limited`, which is exactly what gates the adaptive Z.AI - # backoff below. Detect the overload 429 directly so its adaptive - # long-backoff schedule still runs, and raise the retry ceiling so - # the long tier (30/60/90/120s) is actually reachable: the default - # ceiling equals the short-retry threshold, so the loop otherwise - # gives up after a few quick retries and the long tier is dead code. + # Z.AI Coding Plan GLM-5.2 overload 429s classify as + # `overloaded` (to spare the credential pool), but `overloaded` + # is excluded from `is_rate_limited` — the gate for the adaptive + # Z.AI backoff below. Detect the overload directly so its + # long-backoff schedule runs, and raise the retry ceiling so the + # long tier (30/60/90/120s) is reachable. See + # zai_coding_overload_retry_ceiling() for the ceiling rationale. _is_zai_coding_overload = is_zai_coding_overload_error( base_url=str(_base), model=_model, error=api_error ) diff --git a/agent/retry_utils.py b/agent/retry_utils.py index 3fc5645edb6f..c4971122394f 100644 --- a/agent/retry_utils.py +++ b/agent/retry_utils.py @@ -24,6 +24,14 @@ _jitter_lock = threading.Lock() # not sit silent for 20+ minutes. _ZAI_CODING_OVERLOAD_LONG_BACKOFF = (30.0, 60.0, 90.0, 120.0) +# Number of initial short retries before the adaptive long-backoff tier kicks +# in. Shared by ``adaptive_rate_limit_backoff`` (which walks the long table +# starting at attempt ``short_attempts + 1``) and +# ``zai_coding_overload_retry_ceiling`` (which sizes the retry loop so every +# long-tier entry is reachable). Keeping it a single module constant prevents +# the two from silently desyncing if the short-retry count is ever tuned. +_ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS = 3 + def jittered_backoff( attempt: int, @@ -104,7 +112,7 @@ def adaptive_rate_limit_backoff( model: str | None, error: Any, default_wait: float, - short_attempts: int = 3, + short_attempts: int = _ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS, ) -> tuple[float, str | None]: """Provider-aware rate-limit backoff. @@ -129,7 +137,7 @@ def adaptive_rate_limit_backoff( return jittered_backoff(1, base_delay=base_delay, max_delay=base_delay, jitter_ratio=0.2), "zai_coding_overload_long" -def zai_coding_overload_retry_ceiling(short_attempts: int = 3) -> int: +def zai_coding_overload_retry_ceiling(short_attempts: int = _ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS) -> int: """Retry-loop ceiling needed for the full Z.AI overload backoff schedule. The adaptive policy runs ``short_attempts`` short retries, then walks the diff --git a/tests/test_retry_utils.py b/tests/test_retry_utils.py index 460a9b8c0f33..49bdb09ec0ba 100644 --- a/tests/test_retry_utils.py +++ b/tests/test_retry_utils.py @@ -224,10 +224,13 @@ def test_zai_overload_retry_ceiling_exceeds_short_attempts(): short_attempts = 3 ceiling = zai_coding_overload_retry_ceiling(short_attempts) assert ceiling > short_attempts - # Room for every long-backoff entry to run before giving up: the loop's - # give-up check (retry_count >= ceiling) runs before the attempt's backoff, - # so the ceiling sits one past the final long entry. - assert ceiling == short_attempts + len(_ZAI_CODING_OVERLOAD_LONG_BACKOFF) + 1 + # Invariant (not a formula mirror): the loop's give-up check + # (retry_count >= ceiling) runs *before* the attempt's backoff, so the + # ceiling must leave headroom for every long-backoff entry to execute — + # i.e. the largest attempt the loop still computes backoff for + # (ceiling - 1) must reach the final long-tier index. + last_attempt_with_backoff = ceiling - 1 + assert last_attempt_with_backoff - short_attempts >= len(_ZAI_CODING_OVERLOAD_LONG_BACKOFF) def test_zai_overload_ceiling_makes_long_tier_reachable(monkeypatch): From b2213ba87017c1984091cfb51cf6f5704adbb65e Mon Sep 17 00:00:00 2001 From: spiky02plateau Date: Tue, 26 May 2026 23:34:31 +0200 Subject: [PATCH 039/610] fix: fetch Codex quota from credential pool --- agent/account_usage.py | 49 ++++++++++--- tests/agent/test_account_usage.py | 110 ++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 tests/agent/test_account_usage.py diff --git a/agent/account_usage.py b/agent/account_usage.py index da02af3c478c..3e688d919aca 100644 --- a/agent/account_usage.py +++ b/agent/account_usage.py @@ -436,20 +436,53 @@ def _resolve_codex_usage_url(base_url: str) -> str: return normalized + "/api/codex/usage" -def _fetch_codex_account_usage() -> Optional[AccountUsageSnapshot]: - creds = resolve_codex_runtime_credentials(refresh_if_expiring=True) - token_data = _read_codex_tokens() - tokens = token_data.get("tokens") or {} - account_id = str(tokens.get("account_id", "") or "").strip() or None +def _resolve_codex_usage_credentials( + base_url: Optional[str], + api_key: Optional[str], +) -> tuple[str, str, Optional[str]]: + """Resolve Codex quota credentials from the native runtime path. + + Prefer explicit live-agent credentials, then the legacy singleton OAuth + state, then the credential pool. Hermes's native OAuth setup now stores + device-code logins in the pool, so quota diagnostics must not depend only + on the older singleton store. + """ + explicit_key = str(api_key or "").strip() + if explicit_key: + return explicit_key, str(base_url or "").strip(), None + + try: + creds = resolve_codex_runtime_credentials(refresh_if_expiring=True) + token_data = _read_codex_tokens() + tokens = token_data.get("tokens") or {} + account_id = str(tokens.get("account_id", "") or "").strip() or None + return creds["api_key"], str(creds.get("base_url", "") or "").strip(), account_id + except Exception: + pass + + from agent.credential_pool import load_pool + + pool = load_pool("openai-codex") + entry = pool.select() + if entry is None: + raise RuntimeError("No available openai-codex credential in credential pool") + return entry.runtime_api_key, str(entry.runtime_base_url or base_url or "").strip(), None + + +def _fetch_codex_account_usage( + base_url: Optional[str] = None, + api_key: Optional[str] = None, +) -> Optional[AccountUsageSnapshot]: + token, resolved_base_url, account_id = _resolve_codex_usage_credentials(base_url, api_key) headers = { - "Authorization": f"Bearer {creds['api_key']}", + "Authorization": f"Bearer {token}", "Accept": "application/json", "User-Agent": "codex-cli", } if account_id: headers["ChatGPT-Account-Id"] = account_id with httpx.Client(timeout=15.0) as client: - response = client.get(_resolve_codex_usage_url(creds.get("base_url", "")), headers=headers) + response = client.get(_resolve_codex_usage_url(resolved_base_url), headers=headers) response.raise_for_status() payload = response.json() or {} rate_limit = payload.get("rate_limit") or {} @@ -628,7 +661,7 @@ def fetch_account_usage( return None try: if normalized == "openai-codex": - return _fetch_codex_account_usage() + return _fetch_codex_account_usage(base_url=base_url, api_key=api_key) if normalized == "anthropic": return _fetch_anthropic_account_usage() if normalized == "openrouter": diff --git a/tests/agent/test_account_usage.py b/tests/agent/test_account_usage.py new file mode 100644 index 000000000000..27068f3b3aa7 --- /dev/null +++ b/tests/agent/test_account_usage.py @@ -0,0 +1,110 @@ +from types import SimpleNamespace + +import pytest + +from agent import account_usage + + +class _FakeResponse: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self._payload + + +class _FakeClient: + def __init__(self, calls, payload): + self.calls = calls + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def get(self, url, headers): + self.calls.append({"url": url, "headers": headers}) + return _FakeResponse(self.payload) + + +@pytest.fixture +def codex_usage_payload(): + return { + "plan_type": "plus", + "rate_limit": { + "primary_window": { + "used_percent": 21, + "reset_at": 1779846359, + }, + "secondary_window": { + "used_percent": 4, + "reset_at": 1780230796, + }, + }, + "credits": {"has_credits": False}, + } + + +def test_codex_usage_prefers_explicit_live_agent_credentials(monkeypatch, codex_usage_payload): + calls = [] + monkeypatch.setattr( + account_usage.httpx, + "Client", + lambda timeout: _FakeClient(calls, codex_usage_payload), + ) + monkeypatch.setattr( + account_usage, + "resolve_codex_runtime_credentials", + lambda **kwargs: (_ for _ in ()).throw(AssertionError("legacy auth should not be used")), + ) + + snapshot = account_usage.fetch_account_usage( + "openai-codex", + base_url="https://chatgpt.com/backend-api/codex", + api_key="live-agent-token", + ) + + assert snapshot is not None + assert snapshot.provider == "openai-codex" + assert snapshot.plan == "Plus" + assert [w.label for w in snapshot.windows] == ["Session", "Weekly"] + assert snapshot.windows[0].used_percent == 21 + assert calls[0]["url"] == "https://chatgpt.com/backend-api/wham/usage" + assert calls[0]["headers"]["Authorization"] == "Bearer live-agent-token" + + +def test_codex_usage_falls_back_to_native_credential_pool(monkeypatch, codex_usage_payload): + calls = [] + monkeypatch.setattr( + account_usage.httpx, + "Client", + lambda timeout: _FakeClient(calls, codex_usage_payload), + ) + monkeypatch.setattr( + account_usage, + "resolve_codex_runtime_credentials", + lambda **kwargs: (_ for _ in ()).throw(RuntimeError("no singleton auth")), + ) + + pool_entry = SimpleNamespace( + runtime_api_key="pooled-token", + runtime_base_url="https://chatgpt.com/backend-api/codex", + ) + pool = SimpleNamespace(select=lambda: pool_entry) + + import agent.credential_pool as credential_pool + + monkeypatch.setattr(credential_pool, "load_pool", lambda provider: pool) + + snapshot = account_usage.fetch_account_usage("openai-codex") + + assert snapshot is not None + assert snapshot.windows[0].label == "Session" + assert snapshot.windows[1].label == "Weekly" + assert calls[0]["url"] == "https://chatgpt.com/backend-api/wham/usage" + assert calls[0]["headers"]["Authorization"] == "Bearer pooled-token" From c59b3008653f7d305a19907fd9f969e76b4fdea8 Mon Sep 17 00:00:00 2001 From: spiky02plateau Date: Wed, 27 May 2026 00:59:32 +0200 Subject: [PATCH 040/610] test: lock Codex usage percent polarity --- tests/agent/test_account_usage.py | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/agent/test_account_usage.py b/tests/agent/test_account_usage.py index 27068f3b3aa7..acf1745cbfce 100644 --- a/tests/agent/test_account_usage.py +++ b/tests/agent/test_account_usage.py @@ -108,3 +108,46 @@ def test_codex_usage_falls_back_to_native_credential_pool(monkeypatch, codex_usa assert snapshot.windows[1].label == "Weekly" assert calls[0]["url"] == "https://chatgpt.com/backend-api/wham/usage" assert calls[0]["headers"]["Authorization"] == "Bearer pooled-token" + + +def test_codex_usage_treats_wham_used_percent_as_used_not_remaining(monkeypatch): + """ChatGPT UI says "left"; /wham/usage.used_percent is already used.""" + payload = { + "plan_type": "plus", + "rate_limit": { + "primary_window": { + "used_percent": 85, + "reset_at": 1779846359, + }, + "secondary_window": { + "used_percent": 14, + "reset_at": 1780230796, + }, + }, + "credits": {"has_credits": False}, + } + calls = [] + monkeypatch.setattr( + account_usage.httpx, + "Client", + lambda timeout: _FakeClient(calls, payload), + ) + monkeypatch.setattr( + account_usage, + "resolve_codex_runtime_credentials", + lambda **kwargs: (_ for _ in ()).throw(AssertionError("explicit auth should be used")), + ) + + snapshot = account_usage.fetch_account_usage( + "openai-codex", + base_url="https://chatgpt.com/backend-api/codex", + api_key="live-agent-token", + ) + + assert snapshot is not None + assert [window.used_percent for window in snapshot.windows] == [85, 14] + rendered = "\n".join(account_usage.render_account_usage_lines(snapshot, markdown=True)) + assert "85% used" in rendered + assert "14% used" in rendered + assert "15% used" not in rendered + assert "86% used" not in rendered From 130e2337c24810bc0afa793495795b832cf593be Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:41:39 +0530 Subject: [PATCH 041/610] fix(usage): scope Codex usage pool fallback to AuthError, keep singleton token on account_id read failure Follow-up hardening on the cherry-picked pool-fallback fix. The original _resolve_codex_usage_credentials wrapped BOTH resolve_codex_runtime_credentials() and the separate _read_codex_tokens() account_id read in one broad 'except Exception: pass', which had three problems: 1. A transient refresh/network failure (non-AuthError) from the resolver was silently swallowed and downgraded to pool.select(), which could report /usage limits for a DIFFERENT pool account than the one actually running. On main that error surfaced. This is a real behavior regression for the multi-account/pool case. 2. If the resolver succeeded but only the account_id read raised, the whole singleton tier was abandoned in favor of a pool token that carries no ChatGPT-Account-Id header (PooledCredential has no account_id concept), risking a wrong-account read or 401. 3. 'except Exception' masked genuine programming errors. Fix: narrow the outer catch to AuthError (the documented 'no creds' failure mode of both functions), and read account_id in a best-effort inner try so a partial/missing singleton store can't sink an otherwise-usable credential. Transient errors now propagate and fail open via the outer fetch_account_usage guard rather than mis-routing to the wrong account. Adds debug breadcrumbs and a comment characterizing when the tier-3 pool path actually fires. Guard tests: a non-AuthError resolver failure must NOT swap to the pool (fail-open, no snapshot); an account_id read failure keeps the singleton token. Updated the existing pool-fallback test to use AuthError (the real failure mode) instead of a generic RuntimeError. --- agent/account_usage.py | 37 ++++++++++--- tests/agent/test_account_usage.py | 86 ++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 7 deletions(-) diff --git a/agent/account_usage.py b/agent/account_usage.py index 3e688d919aca..712e57feda4a 100644 --- a/agent/account_usage.py +++ b/agent/account_usage.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, Optional import httpx from agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token -from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials +from hermes_cli.auth import AuthError, _read_codex_tokens, resolve_codex_runtime_credentials from hermes_cli.runtime_provider import resolve_runtime_provider if TYPE_CHECKING: @@ -451,15 +451,40 @@ def _resolve_codex_usage_credentials( if explicit_key: return explicit_key, str(base_url or "").strip(), None + # Tier 2: the native runtime resolver. It ALREADY falls back to the + # credential pool when the singleton is empty (see + # ``resolve_codex_runtime_credentials`` — issue #32992), so in a pool-only + # setup this returns a usable ``source="credential_pool"`` token. + # + # Only ``AuthError`` ("no creds" / rate-limited) is caught so tier 3 can + # run: a broad ``except Exception`` would (a) mask a transient refresh / + # network failure and silently hand back a DIFFERENT pool account's usage, + # and (b) hide genuine programming errors. A refresh/network error must + # propagate — the outer ``fetch_account_usage`` guard fails open (shows + # nothing this turn) rather than reporting the wrong account. + # + # The ``account_id`` (for the ``ChatGPT-Account-Id`` header) is read + # best-effort: a partial/missing singleton token store must not sink an + # otherwise-usable resolver credential and force a header-less pool fallback. try: creds = resolve_codex_runtime_credentials(refresh_if_expiring=True) - token_data = _read_codex_tokens() - tokens = token_data.get("tokens") or {} - account_id = str(tokens.get("account_id", "") or "").strip() or None + account_id: Optional[str] = None + try: + token_data = _read_codex_tokens() + tokens = token_data.get("tokens") or {} + account_id = str(tokens.get("account_id", "") or "").strip() or None + except AuthError: + # Pool-only creds carry no singleton account_id; header is optional. + logger.debug("codex ▸ /usage account_id read failed (best-effort)", exc_info=True) return creds["api_key"], str(creds.get("base_url", "") or "").strip(), account_id - except Exception: - pass + except AuthError: + logger.debug("codex ▸ /usage runtime resolver returned no creds; trying pool", exc_info=True) + # Tier 3: direct pool select. Reached only when the resolver itself raises + # AuthError (e.g. singleton missing AND its own pool read found nothing at + # resolve time, but a pool entry is usable now). Pool credentials have no + # account_id concept, so the ChatGPT-Account-Id header is intentionally + # omitted here. from agent.credential_pool import load_pool pool = load_pool("openai-codex") diff --git a/tests/agent/test_account_usage.py b/tests/agent/test_account_usage.py index acf1745cbfce..41950c70a12c 100644 --- a/tests/agent/test_account_usage.py +++ b/tests/agent/test_account_usage.py @@ -85,10 +85,15 @@ def test_codex_usage_falls_back_to_native_credential_pool(monkeypatch, codex_usa "Client", lambda timeout: _FakeClient(calls, codex_usage_payload), ) + # Pool fallback fires only on AuthError (the documented "no creds" mode of + # the resolver), NOT on arbitrary exceptions — see the transient-error guard + # test below. monkeypatch.setattr( account_usage, "resolve_codex_runtime_credentials", - lambda **kwargs: (_ for _ in ()).throw(RuntimeError("no singleton auth")), + lambda **kwargs: (_ for _ in ()).throw( + account_usage.AuthError("no singleton auth", provider="openai-codex", code="codex_auth_missing") + ), ) pool_entry = SimpleNamespace( @@ -108,6 +113,85 @@ def test_codex_usage_falls_back_to_native_credential_pool(monkeypatch, codex_usa assert snapshot.windows[1].label == "Weekly" assert calls[0]["url"] == "https://chatgpt.com/backend-api/wham/usage" assert calls[0]["headers"]["Authorization"] == "Bearer pooled-token" + # Pool creds have no account_id concept — the ChatGPT-Account-Id header must + # be omitted rather than sent stale/wrong. + assert "ChatGPT-Account-Id" not in calls[0]["headers"] + + +def test_codex_usage_does_not_swap_to_pool_on_transient_resolver_error(monkeypatch, codex_usage_payload): + """A transient refresh/network failure (non-AuthError) must NOT silently + downgrade to a possibly-different pool account. It fails open (no snapshot) + instead of reporting the wrong account's usage.""" + calls = [] + monkeypatch.setattr( + account_usage.httpx, + "Client", + lambda timeout: _FakeClient(calls, codex_usage_payload), + ) + monkeypatch.setattr( + account_usage, + "resolve_codex_runtime_credentials", + lambda **kwargs: (_ for _ in ()).throw(RuntimeError("refresh endpoint 503")), + ) + + pool_entry = SimpleNamespace( + runtime_api_key="pooled-token-WRONG-ACCOUNT", + runtime_base_url="https://chatgpt.com/backend-api/codex", + ) + pool = SimpleNamespace(select=lambda: pool_entry) + + import agent.credential_pool as credential_pool + + # If the guard regressed, this pool would be consulted and return a snapshot + # for the wrong account. It must NOT be. + monkeypatch.setattr(credential_pool, "load_pool", lambda provider: pool) + + snapshot = account_usage.fetch_account_usage("openai-codex") + + assert snapshot is None + assert calls == [] # HTTP usage endpoint never hit with a wrong-account token + + +def test_codex_usage_account_id_read_failure_keeps_singleton_token(monkeypatch, codex_usage_payload): + """When the resolver succeeds but the separate account_id read raises, the + working singleton token must still be used (best-effort account_id), NOT + abandoned in favor of a header-less pool credential.""" + calls = [] + monkeypatch.setattr( + account_usage.httpx, + "Client", + lambda timeout: _FakeClient(calls, codex_usage_payload), + ) + monkeypatch.setattr( + account_usage, + "resolve_codex_runtime_credentials", + lambda **kwargs: { + "api_key": "singleton-token", + "base_url": "https://chatgpt.com/backend-api/codex", + }, + ) + monkeypatch.setattr( + account_usage, + "_read_codex_tokens", + lambda *a, **k: (_ for _ in ()).throw( + account_usage.AuthError("partial store", provider="openai-codex", code="codex_auth_invalid_shape") + ), + ) + + import agent.credential_pool as credential_pool + + monkeypatch.setattr( + credential_pool, + "load_pool", + lambda provider: (_ for _ in ()).throw(AssertionError("pool must not be consulted")), + ) + + snapshot = account_usage.fetch_account_usage("openai-codex") + + assert snapshot is not None + assert calls[0]["headers"]["Authorization"] == "Bearer singleton-token" + # account_id read failed → header omitted, but the singleton token is kept. + assert "ChatGPT-Account-Id" not in calls[0]["headers"] def test_codex_usage_treats_wham_used_percent_as_used_not_remaining(monkeypatch): From ef599aa7f02d2a9c6af6c2b6dac16036d4b82819 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:56:34 +0530 Subject: [PATCH 042/610] chore: map spiky02plateau in AUTHOR_MAP for #32824 salvage The salvaged commits from #32824 use a bare spiky02plateau@users.noreply.github.com (no numeric-id + prefix), which the contributor-check.yml gate does not auto-resolve (it only skips the +@users.noreply form). Add the explicit mapping so attribution CI passes and release notes credit @spiky02plateau. --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 98fc9dfe28bd..4dcc16f4d256 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "spiky02plateau@users.noreply.github.com": "spiky02plateau", # PR #32824 salvage (usage: fetch Codex account limits from the credential pool in pool-only setups; superseded by #60028) "taylorhp@gmail.com": "hwrdprkns", # PR #36896 salvage (secrets: 1Password op:// secret source + shared _cache substrate, adapted onto the SecretSource interface) "ishengeqi@163.com": "isheng-eqi", # PR #59428 salvage (cron: reject past one-shot timestamps in update_job fallback + resume_job; #59395). Also PR #59446 salvage (cron: advance one-shot next_run_at before dispatch so concurrent gateway+desktop schedulers can't double-execute; #59229). "derek2000139@qq.com": "derek2000139", # PR #57838 salvage (desktop/windows: pre-write update marker before quit dwell so the renderer's waitForUpdateToFinish gate parks instead of respawning a backend that re-locks venv .pyd files mid-update) From 536ffedbf4704f220fc184cc3dc1ef0bd5f91fb7 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Tue, 7 Jul 2026 16:57:23 +1000 Subject: [PATCH 043/610] feat(docker): re-seed a terminally-dead Nous bootstrap session on boot (#59983) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stage2-hook auth.json seed is first-boot-only ([ ! -f auth.json ]) to avoid clobbering rotated refresh tokens on restart. That guard means a container whose Nous bootstrap session took a terminal invalid_grant (tokens cleared, providers.nous.last_auth_error.relogin_required stamped) cannot recover from a restart — it stays unauthenticated until the credential is replaced. Add a self-heal path: an orchestrator that manages the container supplies a freshly-issued session via HERMES_AUTH_JSON_REBOOTSTRAP (distinct from the create-only *_BOOTSTRAP var). On boot, scripts/docker_rebootstrap_nous_session.py swaps ONLY the providers.nous entry, and ONLY when the on-disk entry is provably terminal (quarantine marker + no usable tokens). Healthy/rotating/absent/ unparseable auth.json is always a no-op, so the env is safe to leave set across restarts and never clobbers a good token. Pure stdlib, runs as its own subprocess, always exits 0 so a re-seed error never fails the boot. Reuses the same terminal predicate as get_nous_session_validity() so we re-seed only a session that is genuinely dead. --- docker/stage2-hook.sh | 25 +++ scripts/docker_rebootstrap_nous_session.py | 171 ++++++++++++++++++ .../test_docker_rebootstrap_nous_session.py | 140 ++++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 scripts/docker_rebootstrap_nous_session.py create mode 100644 tests/tools/test_docker_rebootstrap_nous_session.py diff --git a/docker/stage2-hook.sh b/docker/stage2-hook.sh index b73afdd37727..e6d1af35298c 100755 --- a/docker/stage2-hook.sh +++ b/docker/stage2-hook.sh @@ -443,6 +443,31 @@ if [ ! -f "$HERMES_HOME/auth.json" ] && [ -n "${HERMES_AUTH_JSON_BOOTSTRAP:-}" ] fi fi +# auth.json: re-seed a TERMINALLY-DEAD Nous bootstrap session (self-heal). +# +# The [ ! -f ] guard above deliberately refuses to clobber an existing +# auth.json, so a container whose Nous bootstrap session took a terminal +# invalid_grant (tokens cleared, providers.nous.last_auth_error.relogin_required +# stamped) can NOT recover from a plain restart — it stays unauthenticated until +# the credential is replaced. An orchestrator that manages the container can +# supply a freshly-issued session via HERMES_AUTH_JSON_REBOOTSTRAP (distinct +# from the create-only *_BOOTSTRAP var); this helper swaps ONLY the +# providers.nous entry, and ONLY when the on-disk entry is provably terminal. +# Every other case (healthy, rotating, absent, or unparseable auth.json) is a +# no-op, so it is safe to leave the env set across restarts and never risks +# clobbering a good/rotated token. Runs as its own stdlib-only subprocess (no +# app imports) and always exits 0. +if [ -f "$HERMES_HOME/auth.json" ] && [ -n "${HERMES_AUTH_JSON_REBOOTSTRAP:-}" ]; then + if refuse_symlinked_path "reseed" "$HERMES_HOME/auth.json"; then + : + else + s6-setuidgid hermes "$INSTALL_DIR/.venv/bin/python" \ + "$INSTALL_DIR/scripts/docker_rebootstrap_nous_session.py" \ + "$HERMES_HOME/auth.json" \ + || echo "[stage2] Warning: docker_rebootstrap_nous_session.py failed; continuing" + fi +fi + # gateway_state.json: declare the gateway's INITIAL supervised state on a # fresh volume. Same first-boot-only env-seed pattern as auth.json above. # diff --git a/scripts/docker_rebootstrap_nous_session.py b/scripts/docker_rebootstrap_nous_session.py new file mode 100644 index 000000000000..a28a52e3c9bc --- /dev/null +++ b/scripts/docker_rebootstrap_nous_session.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Boot-time re-seed of a terminally-dead Nous bootstrap session. + +Background +---------- +A Nous bootstrap session (client_id ``hermes-cli-vps``) can take a terminal +``invalid_grant`` and be quarantined locally — the refresh path clears the dead +tokens from ``auth.json`` and stamps +``providers.nous.last_auth_error.relogin_required = true``. From then on every +inference turn hard-fails with a provider-auth error until the credential is +replaced, even though the gateway and dashboard otherwise look healthy. + +``stage2-hook.sh`` seeds ``auth.json`` from ``HERMES_AUTH_JSON_BOOTSTRAP`` only +on a *blank* volume (``[ ! -f auth.json ]``) — that guard is load-bearing: it +stops a container restart from clobbering a healthy, rotated refresh token. So a +plain restart with a fresh seed env can NOT recover a container whose volume +already has an auth.json. + +This script is the narrow, safe exception. An orchestrator that manages the +container can supply a freshly-issued bootstrap session via +``HERMES_AUTH_JSON_REBOOTSTRAP`` (plus a restart). On boot we re-seed the Nous +provider entry from that env **only when the on-disk Nous entry is provably +terminal** (the quarantine marker above with no usable tokens left). Every other +case is a no-op, so we never clobber a healthy or merely-rotating session. + +Design constraints +------------------ +- Pure stdlib, no hermes_cli imports: runs early in the boot hook, before the + app venv/modules are guaranteed importable, as its own subprocess. +- Surgical: replaces ONLY ``providers.nous`` in the existing auth.json, leaving + every other provider, the version, and any other top-level state untouched. +- Fail-safe: any parse/IO error leaves auth.json exactly as-is and exits 0 (a + failed re-seed must never take the container further down than it already is). +""" +from __future__ import annotations + +import json +import os +import sys +from typing import Any, Optional + +# Env var the orchestrator sets to the re-seed payload. Deliberately DISTINCT +# from HERMES_AUTH_JSON_BOOTSTRAP (create-only, blank-volume seed) so the two +# paths can never be confused: BOOTSTRAP seeds a fresh volume; REBOOTSTRAP +# overwrites a terminally-dead Nous entry on an existing volume. +REBOOTSTRAP_ENV = "HERMES_AUTH_JSON_REBOOTSTRAP" + + +def _nous_entry_is_terminal(nous_state: Any) -> bool: + """True iff the on-disk Nous provider entry is in the terminal/quarantined + state AND holds no usable credential. + + Mirrors the ``terminal`` predicate in ``hermes_cli.auth.get_nous_session_validity``: + a persisted ``last_auth_error.relogin_required`` with the token material + already cleared. Keeping this in lockstep is what guarantees we only re-seed + a session that is genuinely dead. + """ + if not isinstance(nous_state, dict): + return False + last_err = nous_state.get("last_auth_error") + if not (isinstance(last_err, dict) and last_err.get("relogin_required")): + return False + # Only terminal while there is no usable credential left. If a live token is + # somehow present, treat it as healthy and do NOT clobber it. + if nous_state.get("access_token") or nous_state.get("refresh_token"): + return False + return True + + +def _extract_nous_from_seed(seed_raw: str) -> Optional[dict]: + """Pull the ``providers.nous`` block out of a HERMES_AUTH_JSON_REBOOTSTRAP + payload. The payload is a full auth.json document (same shape as + HERMES_AUTH_JSON_BOOTSTRAP). Returns None if it can't be parsed or carries no + nous entry — caller treats None as "nothing to do".""" + try: + seed = json.loads(seed_raw) + except (ValueError, TypeError): + return None + if not isinstance(seed, dict): + return None + providers = seed.get("providers") + if not isinstance(providers, dict): + return None + nous = providers.get("nous") + if not isinstance(nous, dict) or not nous: + return None + return nous + + +def reseed_if_terminal(auth_path: str, seed_raw: str) -> str: + """Core logic. Returns a short status string for logging/testing: + + - "no_seed" — seed env empty/absent + - "bad_seed" — seed present but unparseable / no nous entry + - "no_auth_file" — auth.json absent (blank volume → let the normal + HERMES_AUTH_JSON_BOOTSTRAP path handle it) + - "auth_unreadable" — auth.json present but unparseable (leave as-is) + - "not_terminal" — on-disk nous entry is healthy/absent → no-op + - "reseeded" — nous entry was terminal; replaced from seed + """ + if not seed_raw: + return "no_seed" + + seed_nous = _extract_nous_from_seed(seed_raw) + if seed_nous is None: + return "bad_seed" + + if not os.path.exists(auth_path): + # Blank volume — this is the normal first-boot case, not a re-seed. + return "no_auth_file" + + try: + with open(auth_path, "r", encoding="utf-8") as fh: + store = json.load(fh) + except (OSError, ValueError): + # Corrupt/unreadable auth.json: do NOT overwrite blindly. A separate + # concern; leave it for the operator / other recovery paths. + return "auth_unreadable" + + if not isinstance(store, dict): + return "auth_unreadable" + + providers = store.get("providers") + if not isinstance(providers, dict): + providers = {} + store["providers"] = providers + + if not _nous_entry_is_terminal(providers.get("nous")): + # Healthy, rotating, or absent nous entry — the load-bearing guard. + # Never clobber a good session; this is what makes the re-seed safe to + # push on every restart. + return "not_terminal" + + # Surgical replacement: swap ONLY providers.nous, preserve everything else. + providers["nous"] = seed_nous + + tmp_path = f"{auth_path}.rebootstrap.tmp" + with open(tmp_path, "w", encoding="utf-8") as fh: + json.dump(store, fh) + os.replace(tmp_path, auth_path) + try: + os.chmod(auth_path, 0o600) + except OSError: + pass + return "reseeded" + + +def main() -> int: + auth_path = sys.argv[1] if len(sys.argv) > 1 else "" + if not auth_path: + home = os.environ.get("HERMES_HOME", "") + auth_path = os.path.join(home, "auth.json") if home else "auth.json" + seed_raw = os.environ.get(REBOOTSTRAP_ENV, "") + + try: + result = reseed_if_terminal(auth_path, seed_raw) + except Exception as exc: # never let a re-seed error fail the boot + print(f"[rebootstrap] error (ignored): {exc!r}", file=sys.stderr) + return 0 + + if result == "reseeded": + print("[rebootstrap] Nous bootstrap session was terminal; re-seeded auth.json from " + f"{REBOOTSTRAP_ENV}") + else: + # Quiet by default for the common no-op cases; still emit a breadcrumb. + print(f"[rebootstrap] no-op ({result})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/tools/test_docker_rebootstrap_nous_session.py b/tests/tools/test_docker_rebootstrap_nous_session.py new file mode 100644 index 000000000000..a6cc02b97805 --- /dev/null +++ b/tests/tools/test_docker_rebootstrap_nous_session.py @@ -0,0 +1,140 @@ +"""Unit tests for scripts/docker_rebootstrap_nous_session.py. + +The boot-time re-seed is the load-bearing "does not clobber a healthy session" +guard: it must overwrite the on-disk Nous provider entry ONLY when that entry is +provably terminal (quarantine marker + no usable tokens), and no-op in every +other case. These are pure-stdlib tmp_path tests (no container build). +""" +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +# Import the stdlib-only boot helper by path (it lives under scripts/, not an +# installed package) — mirrors the repo's other scripts/-helper tests. +_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "docker_rebootstrap_nous_session.py" +_spec = importlib.util.spec_from_file_location("docker_rebootstrap_nous_session", _SCRIPT) +mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(mod) + + +def _terminal_nous_state(): + """On-disk shape after a terminal quarantine: tokens cleared, marker set.""" + return { + "portal_base_url": "https://portal.example.com", + "client_id": "hermes-cli-vps", + "last_auth_error": { + "provider": "nous", + "code": "invalid_grant", + "relogin_required": True, + }, + } + + +def _healthy_nous_state(): + return { + "portal_base_url": "https://portal.example.com", + "client_id": "hermes-cli-vps", + "access_token": "live-at", + "refresh_token": "live-rt", + } + + +def _write_auth(tmp_path: Path, providers: dict) -> str: + p = tmp_path / "auth.json" + p.write_text(json.dumps({"version": 1, "providers": providers})) + return str(p) + + +_FRESH_SEED = json.dumps({ + "version": 1, + "providers": { + "nous": { + "portal_base_url": "https://portal.example.com", + "client_id": "hermes-cli-vps", + "access_token": "FRESH-at", + "refresh_token": "FRESH-rt", + } + }, +}) + + +def test_reseeds_terminal_entry(tmp_path): + """Terminal on-disk entry + valid seed → providers.nous replaced.""" + auth = _write_auth(tmp_path, {"nous": _terminal_nous_state()}) + result = mod.reseed_if_terminal(auth, _FRESH_SEED) + assert result == "reseeded" + store = json.loads(Path(auth).read_text()) + assert store["providers"]["nous"]["refresh_token"] == "FRESH-rt" + assert "last_auth_error" not in store["providers"]["nous"] + + +def test_does_not_clobber_healthy_entry(tmp_path): + """LOAD-BEARING: a healthy (live-token) entry must never be overwritten.""" + auth = _write_auth(tmp_path, {"nous": _healthy_nous_state()}) + result = mod.reseed_if_terminal(auth, _FRESH_SEED) + assert result == "not_terminal" + store = json.loads(Path(auth).read_text()) + # Untouched — still the live tokens, not the seed. + assert store["providers"]["nous"]["refresh_token"] == "live-rt" + + +def test_marker_but_live_token_is_not_terminal(tmp_path): + """Stale marker + a live token present → NOT terminal (don't clobber).""" + state = _terminal_nous_state() + state["refresh_token"] = "somehow-live" + auth = _write_auth(tmp_path, {"nous": state}) + assert mod.reseed_if_terminal(auth, _FRESH_SEED) == "not_terminal" + + +def test_preserves_other_providers(tmp_path): + """Re-seed swaps ONLY providers.nous; other providers survive intact.""" + auth = _write_auth(tmp_path, { + "nous": _terminal_nous_state(), + "openai-codex": {"tokens": {"access_token": "codex-at"}}, + }) + assert mod.reseed_if_terminal(auth, _FRESH_SEED) == "reseeded" + store = json.loads(Path(auth).read_text()) + assert store["providers"]["openai-codex"]["tokens"]["access_token"] == "codex-at" + assert store["providers"]["nous"]["refresh_token"] == "FRESH-rt" + + +def test_no_seed_is_noop(tmp_path): + auth = _write_auth(tmp_path, {"nous": _terminal_nous_state()}) + assert mod.reseed_if_terminal(auth, "") == "no_seed" + + +def test_bad_seed_is_noop(tmp_path): + auth = _write_auth(tmp_path, {"nous": _terminal_nous_state()}) + assert mod.reseed_if_terminal(auth, "}{not json") == "bad_seed" + # Original terminal entry left untouched. + store = json.loads(Path(auth).read_text()) + assert store["providers"]["nous"]["last_auth_error"]["relogin_required"] is True + + +def test_seed_without_nous_entry_is_noop(tmp_path): + auth = _write_auth(tmp_path, {"nous": _terminal_nous_state()}) + seed = json.dumps({"version": 1, "providers": {"openai-codex": {}}}) + assert mod.reseed_if_terminal(auth, seed) == "bad_seed" + + +def test_absent_auth_file_defers_to_bootstrap(tmp_path): + """No auth.json → blank volume; the normal *_BOOTSTRAP path handles it.""" + auth = str(tmp_path / "auth.json") + assert mod.reseed_if_terminal(auth, _FRESH_SEED) == "no_auth_file" + + +def test_unreadable_auth_file_is_left_alone(tmp_path): + p = tmp_path / "auth.json" + p.write_text("}{ corrupt") + assert mod.reseed_if_terminal(str(p), _FRESH_SEED) == "auth_unreadable" + # Not overwritten. + assert p.read_text() == "}{ corrupt" + + +def test_terminal_entry_missing_marker_is_not_terminal(tmp_path): + """No last_auth_error at all (e.g. a merely-expired but not-quarantined + entry) → not terminal, no re-seed.""" + auth = _write_auth(tmp_path, {"nous": {"client_id": "hermes-cli-vps"}}) + assert mod.reseed_if_terminal(auth, _FRESH_SEED) == "not_terminal" From 444dc0da89a626e9a912894d4948f56d41942d98 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 7 Jul 2026 13:51:22 +1000 Subject: [PATCH 044/610] feat(auth): log forensic detail at Nous quarantine so terminal auth death is visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A NAS-hosted Fly agent's Nous bootstrap session can take a terminal invalid_grant and get quarantined in _quarantine_nous_oauth_state, which clears the dead tokens from auth.json. Until now this quarantine was completely silent: the only signal was a downstream "No access token found" WARNING once the credential pool was already empty, which is too late to root-cause. Because the Fly log drain is WARNING-only, nothing about the terminal death reached centralized logging, and a real incident could not be diagnosed because the evidence was never recorded. Emit a WARNING+ forensic record AT the quarantine point, before the token material is cleared. Fields: refresh_token hash prefix (12-char SHA-256 hex, correlates to NAS's refreshTokenHash), client_id, agent_key_id, error code, reason, auth.json path/size/mtime/exists, and whether the token was already past its own expiry. WARNING level is deliberate — INFO never reaches the Fly drain. Redaction safety (load-bearing): the log dict is built only from computed values (hash prefix, sizes, booleans). No raw refresh_token, access_token, or agent_key bytes are ever passed into the log call, avoiding Hermes's known credential-literal corruption bug class. A test asserts the raw refresh token substring is absent from all emitted log output. Note: no session_id field exists on Nous auth state; provenance is captured via client_id + agent_key_id, which are non-secret routing identifiers. --- hermes_cli/auth.py | 52 ++++++++ .../test_quarantine_forensic_logging.py | 113 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 tests/hermes_cli/test_quarantine_forensic_logging.py diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 91911e77e174..b499963ea7e8 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -4860,6 +4860,58 @@ def _quarantine_nous_oauth_state( reason: str, ) -> None: """Keep routing metadata but remove dead OAuth material so it is not replayed.""" + # Forensic logging BEFORE we clear the token material. A NAS-hosted Fly agent + # can take a terminal invalid_grant and get quarantined here silently: the + # only downstream signal is a "No access token found" WARNING once the pool + # is already empty, which is too late to root-cause. The Fly log drain is + # WARNING-only, so this MUST be logger.warning (INFO never reaches the drain). + # + # Redaction safety: emit ONLY the 12-char SHA-256 hex prefix of the refresh + # token (correlates to NAS's refreshTokenHash without leaking the secret) plus + # sizes/booleans. NEVER pass a raw token/agent_key into the log call — Hermes + # has a known bug class where credential-shaped literals get corrupted in logs. + forensic: Dict[str, Any] = { + "reason": reason, + "error_code": error.code, + # No session_id field exists on Nous state; provenance is client_id + + # agent_key_id (both non-secret routing identifiers). + "client_id": state.get("client_id"), + "agent_key_id": state.get("agent_key_id"), + "refresh_token_fp": _token_fingerprint(state.get("refresh_token")), + } + + # On-disk integrity of the auth store at the moment of quarantine. + try: + auth_path = _auth_file_path() + forensic["auth_json_path"] = str(auth_path) + try: + st = os.stat(auth_path) + forensic["auth_json_size"] = st.st_size + forensic["auth_json_mtime"] = st.st_mtime + forensic["auth_json_exists"] = True + except FileNotFoundError: + forensic["auth_json_exists"] = False + except Exception as exc: # pragma: no cover - never let logging break quarantine + forensic["auth_json_stat_error"] = repr(exc) + + # Was the token already past its own expiry when it was rejected? + already_expired: Optional[bool] = None + expires_at_raw = state.get("expires_at") + if isinstance(expires_at_raw, str) and expires_at_raw: + try: + parsed = datetime.fromisoformat(expires_at_raw) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + already_expired = parsed < datetime.now(timezone.utc) + except ValueError: + already_expired = None + forensic["token_already_expired"] = already_expired + + logger.warning( + "Nous OAuth state quarantined (terminal auth death): %s", + json.dumps(forensic, sort_keys=True, ensure_ascii=False), + ) + for key in ( "access_token", "refresh_token", diff --git a/tests/hermes_cli/test_quarantine_forensic_logging.py b/tests/hermes_cli/test_quarantine_forensic_logging.py new file mode 100644 index 000000000000..c044c622d188 --- /dev/null +++ b/tests/hermes_cli/test_quarantine_forensic_logging.py @@ -0,0 +1,113 @@ +"""Redaction-safe forensic logging at the Nous OAuth quarantine path. + +A NAS-hosted Fly agent's Nous bootstrap session can take a terminal +``invalid_grant`` and get quarantined (dead tokens cleared from auth.json). +Historically this was completely silent — no WARNING+ record at the terminal +rejection, only a downstream "No access token found" warning once the pool was +already empty. The Fly log drain is WARNING-only, so nothing about the terminal +death reached centralized logging. These tests lock in that +``_quarantine_nous_oauth_state`` now emits a WARNING+ forensic record, and — the +load-bearing assertion — that the raw refresh token never appears in that output. +""" + +import hashlib +import logging + +import hermes_cli.auth as auth +from hermes_cli.auth import AuthError, _quarantine_nous_oauth_state + + +# Sanity guard: make sure we are exercising THIS worktree's module and not the +# editable-installed main checkout that shares the venv. +def test_module_resolves_to_this_worktree(): + assert "worktrees/bootstrap-h2-logging" in auth.__file__, ( + f"auth module resolved to unexpected path: {auth.__file__}" + ) + + +# A distinctive, obviously-fake refresh token so the redaction assertion is +# unambiguous if it ever leaks. +_FAKE_RT = "nous_rt_LEAK_CANARY_do_not_log_raw_0123456789abcdef" +_EXPECTED_FP = hashlib.sha256(_FAKE_RT.encode("utf-8")).hexdigest()[:12] + + +def _make_state(**overrides): + state = { + "portal_base_url": "https://portal.example.com", + "client_id": "test-client-id", + "access_token": "nous_at_SECRET_access_token_material", + "refresh_token": _FAKE_RT, + "agent_key": "nous_agent_key_SECRET_material", + "agent_key_id": "ak-12345", + "expires_at": "2020-01-01T00:00:00+00:00", # in the past + "obtained_at": "2019-12-31T00:00:00+00:00", + } + state.update(overrides) + return state + + +def _error(): + return AuthError( + "invalid_grant: token expired or revoked", + provider="nous", + code="invalid_grant", + relogin_required=True, + ) + + +def test_quarantine_emits_warning(caplog): + state = _make_state() + with caplog.at_level(logging.WARNING, logger="hermes_cli.auth"): + _quarantine_nous_oauth_state(state, _error(), reason="unit_test_quarantine") + + warnings = [r for r in caplog.records if r.levelno >= logging.WARNING] + assert warnings, "expected at least one WARNING+ record from quarantine" + assert any("quarantined" in r.getMessage() for r in warnings) + + +def test_warning_contains_hash_prefix_and_error_code(caplog): + state = _make_state() + with caplog.at_level(logging.WARNING, logger="hermes_cli.auth"): + _quarantine_nous_oauth_state(state, _error(), reason="unit_test_quarantine") + + text = caplog.text + assert _EXPECTED_FP in text, ( + f"expected refresh-token hash prefix {_EXPECTED_FP} in log output" + ) + assert "invalid_grant" in text, "expected error.code in log output" + assert "unit_test_quarantine" in text, "expected reason in log output" + + +def test_raw_refresh_token_never_logged(caplog): + """Load-bearing redaction-safety test: the raw secret must never appear.""" + state = _make_state() + with caplog.at_level(logging.DEBUG, logger="hermes_cli.auth"): + _quarantine_nous_oauth_state(state, _error(), reason="unit_test_quarantine") + + text = caplog.text + assert _FAKE_RT not in text, "RAW refresh token leaked into log output!" + # Belt-and-suspenders: the access token and agent key must not leak either. + assert "nous_at_SECRET_access_token_material" not in text + assert "nous_agent_key_SECRET_material" not in text + + +def test_quarantine_no_refresh_token_does_not_throw(caplog): + state = _make_state() + state.pop("refresh_token", None) + with caplog.at_level(logging.WARNING, logger="hermes_cli.auth"): + # Must not raise even when there is no refresh token to fingerprint. + _quarantine_nous_oauth_state(state, _error(), reason="unit_test_no_rt") + + warnings = [r for r in caplog.records if r.levelno >= logging.WARNING] + assert warnings, "expected a WARNING even when refresh_token is absent" + # Fingerprint should be null/None, and definitely not the canary prefix. + assert _EXPECTED_FP not in caplog.text + + +def test_quarantine_clears_token_material(): + """Regression guard: the quarantine still clears dead token keys.""" + state = _make_state() + _quarantine_nous_oauth_state(state, _error(), reason="unit_test_quarantine") + for key in ("access_token", "refresh_token", "agent_key", "agent_key_id", "expires_at"): + assert key not in state, f"{key} should have been cleared by quarantine" + assert state["last_auth_error"]["code"] == "invalid_grant" From 182256206a43ecad1cde17fb02470ce72f767133 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 7 Jul 2026 15:48:21 +1000 Subject: [PATCH 045/610] test: drop worktree-path sanity guard that fails in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test_module_resolves_to_this_worktree guard asserted auth.__file__ contained 'worktrees/bootstrap-h2-logging' — a local dev crutch to defeat the editable- install trap (venv points at the main checkout). In CI the code lives at /home/runner/work/... so the assertion always fails. It never belonged in the committed suite; the 5 behavioural tests are what matter. --- tests/hermes_cli/test_quarantine_forensic_logging.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/hermes_cli/test_quarantine_forensic_logging.py b/tests/hermes_cli/test_quarantine_forensic_logging.py index c044c622d188..4ac481231952 100644 --- a/tests/hermes_cli/test_quarantine_forensic_logging.py +++ b/tests/hermes_cli/test_quarantine_forensic_logging.py @@ -13,18 +13,9 @@ load-bearing assertion — that the raw refresh token never appears in that outp import hashlib import logging -import hermes_cli.auth as auth from hermes_cli.auth import AuthError, _quarantine_nous_oauth_state -# Sanity guard: make sure we are exercising THIS worktree's module and not the -# editable-installed main checkout that shares the venv. -def test_module_resolves_to_this_worktree(): - assert "worktrees/bootstrap-h2-logging" in auth.__file__, ( - f"auth module resolved to unexpected path: {auth.__file__}" - ) - - # A distinctive, obviously-fake refresh token so the redaction assertion is # unambiguous if it ever leaks. _FAKE_RT = "nous_rt_LEAK_CANARY_do_not_log_raw_0123456789abcdef" From 5eac665252eb9cd85965afd36964b9cfc1322494 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 7 Jul 2026 13:47:02 +1000 Subject: [PATCH 046/610] feat(status): expose nous_session_valid on /api/status for hosted-agent self-heal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hosted agent whose Nous bootstrap session dies terminally (invalid_grant / quarantine) looks HEALTHY to every liveness/connectivity probe — the machine, relay ws, and dashboard all stay up — yet every inference turn hard-fails with a provider-auth error until a human re-logs-in. Nothing currently surfaces that condition to NAS. Add get_nous_session_validity() (valid|terminal|unknown), classified from local auth-store state (no working token required), and report it on the public /api/status payload. NAS's 2-min health sweep reads it and re-mints the bootstrap session in place on 'terminal'. Anti-flap: only a terminal failure (relogin_required / persisted quarantine marker with tokens cleared) maps to 'terminal'; transient/mid-rotation blips and merely-expiring tokens report 'unknown' so a healthy box never triggers a spurious re-mint. Part of the hosted-agent bootstrap-session self-heal (NAS side reads this field). --- hermes_cli/auth.py | 69 ++++++++++++ hermes_cli/web_server.py | 16 +++ .../hermes_cli/test_nous_session_validity.py | 104 ++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 tests/hermes_cli/test_nous_session_validity.py diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index b499963ea7e8..8ac31020defe 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -5968,6 +5968,75 @@ def _compute_nous_auth_status() -> Dict[str, Any]: return _snapshot_nous_pool_status() +# Enum values reported on the dashboard /api/status as ``nous_session_valid``. +# NAS's health sweep re-mints the bootstrap session ONLY on "terminal"; "valid" +# and "unknown" are no-ops. Keep this set small and stable — NAS parses it with +# a permissive schema, so new members are non-breaking but should stay rare. +NOUS_SESSION_VALID = "valid" +NOUS_SESSION_TERMINAL = "terminal" +NOUS_SESSION_UNKNOWN = "unknown" + + +def get_nous_session_validity() -> str: + """Classify the Nous bootstrap session for the dashboard /api/status probe. + + Returns one of: + - ``"valid"`` — a usable Nous credential is present (login healthy). + - ``"terminal"`` — the Nous session has taken a terminal auth failure + (invalid_grant / quarantined / relogin required). This is the sole + signal NAS acts on to re-mint a hosted-agent bootstrap session. + - ``"unknown"`` — indeterminate (no Nous provider state, or a transient/ + non-terminal error). Never triggers a re-mint. + + Determinable with NO working token — it reads local auth-store state only, + which is exactly the condition a dead hosted box is in. + + ANTI-FLAP CONTRACT: only a *terminal* failure maps to "terminal". A normal + mid-rotation blip, a transient network error, or a merely-expiring token + must NOT report "terminal" (that would trigger a spurious NAS re-mint on a + healthy box). We key "terminal" on the auth layer's own terminal signal + (`relogin_required`) plus a persisted quarantine marker, never on a bare + "not logged in". + """ + # A persisted quarantine marker is the strongest, most stable terminal + # signal: the refresh path writes `last_auth_error.relogin_required=True` + # into the Nous provider state when it clears dead tokens (the exact path + # that produced the incident's "No access token found"). Read it directly + # so we report "terminal" even after the in-memory AuthError is long gone. + try: + state = get_provider_auth_state("nous") + except Exception: + state = None + + if state: + last_err = state.get("last_auth_error") + if isinstance(last_err, dict) and last_err.get("relogin_required"): + # Only terminal while there is no usable credential left. If a later + # successful login repopulated tokens, the stale marker must not + # keep reporting terminal. + if not (state.get("access_token") or state.get("refresh_token")): + return NOUS_SESSION_TERMINAL + + try: + status = get_nous_auth_status() + except Exception: + # Status computation itself failed — indeterminate, not terminal. + return NOUS_SESSION_UNKNOWN + + if status.get("logged_in"): + return NOUS_SESSION_VALID + + # Not logged in. Distinguish a terminal (relogin-required) failure from a + # transient / indeterminate one. Only the former is actionable by NAS. + if status.get("relogin_required"): + return NOUS_SESSION_TERMINAL + + # No Nous provider state at all, or a non-terminal not-logged-in condition + # (e.g. a transient refresh error that did not set relogin_required). Treat + # as unknown so a healthy box mid-blip never triggers a re-mint. + return NOUS_SESSION_UNKNOWN + + def get_codex_auth_status() -> Dict[str, Any]: """Status snapshot for Codex auth. diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index a5292224cd55..3da2c370b8a4 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2400,6 +2400,21 @@ async def get_status(profile: Optional[str] = None): # Module not importable yet (early startup) — leave as []. pass + # Nous bootstrap-session validity for the NAS health sweep. A hosted + # agent whose Nous auth dies terminally (invalid_grant / quarantine) + # looks HEALTHY to every liveness/connectivity probe — the machine, + # relay, and this dashboard all stay up — yet every inference turn + # fails. This is the ONLY signal that surfaces that condition, and it + # is determinable with no working token (local auth-store state). NAS + # re-mints the bootstrap session when it reads "terminal". Best-effort: + # never let auth classification break the public liveness probe. + nous_session_valid = "unknown" + try: + from hermes_cli.auth import get_nous_session_validity + nous_session_valid = get_nous_session_validity() + except Exception: + nous_session_valid = "unknown" + # Always-public liveness + auth-gate shape. Safe for external uptime # probes (NAS's wildcard-subdomain liveness probe), the SPA's pre-login # bootstrap, and anyone who can curl the host — i.e. exactly the audience @@ -2422,6 +2437,7 @@ async def get_status(profile: Optional[str] = None): "active_sessions": active_sessions, "auth_required": auth_required, "auth_providers": auth_providers, + "nous_session_valid": nous_session_valid, } # Absolute host paths, the gateway PID, and the internal gateway health diff --git a/tests/hermes_cli/test_nous_session_validity.py b/tests/hermes_cli/test_nous_session_validity.py new file mode 100644 index 000000000000..02673d6ad783 --- /dev/null +++ b/tests/hermes_cli/test_nous_session_validity.py @@ -0,0 +1,104 @@ +"""Tests for get_nous_session_validity — the /api/status classifier NAS reads +to decide whether to re-mint a hosted-agent bootstrap session. + +The anti-flap contract is the load-bearing property: only a *terminal* auth +failure may report "terminal" (a spurious "terminal" triggers an unnecessary +NAS re-mint + machine restart on a healthy box). A mid-rotation blip, a +transient error, or a merely-expiring token must NOT report "terminal". +""" + +import hermes_cli.auth as auth +from hermes_cli.auth import ( + NOUS_SESSION_TERMINAL, + NOUS_SESSION_UNKNOWN, + NOUS_SESSION_VALID, + get_nous_session_validity, +) + + +def _clear_cache(): + auth.invalidate_nous_auth_status_cache() + + +def test_valid_when_logged_in(monkeypatch): + """A healthy login → 'valid'.""" + monkeypatch.setattr(auth, "get_provider_auth_state", lambda p: { + "access_token": "at", "refresh_token": "rt", + }) + monkeypatch.setattr(auth, "get_nous_auth_status", lambda: {"logged_in": True}) + assert get_nous_session_validity() == NOUS_SESSION_VALID + + +def test_terminal_on_persisted_quarantine_marker(monkeypatch): + """A persisted last_auth_error.relogin_required with tokens cleared → + 'terminal'. This is the exact on-disk state the incident produced.""" + monkeypatch.setattr(auth, "get_provider_auth_state", lambda p: { + # tokens cleared by the quarantine path + "last_auth_error": {"relogin_required": True, "code": "invalid_grant"}, + }) + # status would also say not-logged-in, but the marker short-circuits first + monkeypatch.setattr(auth, "get_nous_auth_status", lambda: {"logged_in": False}) + assert get_nous_session_validity() == NOUS_SESSION_TERMINAL + + +def test_terminal_on_relogin_required_status(monkeypatch): + """Not logged in + relogin_required from the live status → 'terminal'.""" + monkeypatch.setattr(auth, "get_provider_auth_state", lambda p: { + "refresh_token": "rt", # present, but status resolution fails terminally + }) + monkeypatch.setattr(auth, "get_nous_auth_status", lambda: { + "logged_in": False, "relogin_required": True, "error_code": "invalid_grant", + }) + assert get_nous_session_validity() == NOUS_SESSION_TERMINAL + + +def test_unknown_when_no_provider_state(monkeypatch): + """No Nous provider state at all → 'unknown' (never terminal).""" + monkeypatch.setattr(auth, "get_provider_auth_state", lambda p: None) + monkeypatch.setattr(auth, "get_nous_auth_status", lambda: {"logged_in": False}) + assert get_nous_session_validity() == NOUS_SESSION_UNKNOWN + + +def test_anti_flap_transient_not_logged_in_is_unknown(monkeypatch): + """ANTI-FLAP: not-logged-in WITHOUT relogin_required (a transient/network + blip) must be 'unknown', NOT 'terminal' — otherwise a healthy box mid-blip + triggers a spurious re-mint.""" + monkeypatch.setattr(auth, "get_provider_auth_state", lambda p: { + "access_token": "at", "refresh_token": "rt", + }) + monkeypatch.setattr(auth, "get_nous_auth_status", lambda: { + "logged_in": False, "error": "connection reset", # no relogin_required + }) + assert get_nous_session_validity() == NOUS_SESSION_UNKNOWN + + +def test_stale_quarantine_marker_ignored_after_relogin(monkeypatch): + """ANTI-FLAP: a leftover last_auth_error marker must NOT report 'terminal' + once a subsequent login has repopulated tokens.""" + monkeypatch.setattr(auth, "get_provider_auth_state", lambda p: { + "access_token": "new-at", "refresh_token": "new-rt", + "last_auth_error": {"relogin_required": True, "code": "invalid_grant"}, + }) + monkeypatch.setattr(auth, "get_nous_auth_status", lambda: {"logged_in": True}) + assert get_nous_session_validity() == NOUS_SESSION_VALID + + +def test_status_exception_is_unknown_not_terminal(monkeypatch): + """If status computation itself throws, that's indeterminate → 'unknown'.""" + monkeypatch.setattr(auth, "get_provider_auth_state", lambda p: {"refresh_token": "rt"}) + + def _boom(): + raise RuntimeError("boom") + + monkeypatch.setattr(auth, "get_nous_auth_status", _boom) + assert get_nous_session_validity() == NOUS_SESSION_UNKNOWN + + +def test_provider_state_exception_falls_through_to_status(monkeypatch): + """If reading provider state throws, fall through to status (don't crash).""" + def _boom(p): + raise RuntimeError("disk error") + + monkeypatch.setattr(auth, "get_provider_auth_state", _boom) + monkeypatch.setattr(auth, "get_nous_auth_status", lambda: {"logged_in": True}) + assert get_nous_session_validity() == NOUS_SESSION_VALID From 2726c213836c19eb897d8d7eac90c75955fe223e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:07:49 -0700 Subject: [PATCH 047/610] feat(display): show file_path in skill_view tool progress lines (#60079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When skill_view loads a supporting file (references/, scripts/, templates/) instead of the main SKILL.md, the CLI quiet-mode line and the friendly tool labels now show 'name → file_path' so it's clear which file was actually read. --- agent/display.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/agent/display.py b/agent/display.py index 060ac1266fa0..5a16a77d00de 100644 --- a/agent/display.py +++ b/agent/display.py @@ -515,6 +515,16 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) - msg = msg[:17] + "..." return f"to {target}: \"{msg}\"" + if tool_name == "skill_view": + name = _oneline(str(args.get("name") or "")) + file_path = args.get("file_path") + if file_path: + file_path = _oneline(str(file_path)) + preview = f"{name} → {file_path}" if name else file_path + else: + preview = name + return _truncate_preview(preview, max_len) if preview else None + key = primary_args.get(tool_name) if not key: for fallback_key in ("query", "text", "command", "path", "name", "prompt", "code", "goal"): @@ -1384,7 +1394,11 @@ def get_cute_tool_message( if tool_name == "skills_list": return _wrap(f"┊ 📚 skills list {args.get('category', 'all')} {dur}") if tool_name == "skill_view": - return _wrap(f"┊ 📚 skill {_trunc(args.get('name', ''), 30)} {dur}") + label = args.get("name", "") + file_path = args.get("file_path") + if file_path: + label = f"{label} → {file_path}" if label else str(file_path) + return _wrap(f"┊ 📚 skill {_trunc(label, 44)} {dur}") if tool_name == "image_generate": return _wrap(f"┊ 🎨 create {_trunc(args.get('prompt', ''), 35)} {dur}") if tool_name == "text_to_speech": From d297568299fbc0481e3edaa9f35d13aa692b20b1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:25:33 -0700 Subject: [PATCH 048/610] fix(gateway): detect config token credential collisions (#59321) Co-authored-by: markoub <2418548+markoub@users.noreply.github.com> --- gateway/run.py | 5 ++ .../test_multiplex_adapter_registry.py | 63 ++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 8256c283d4cf..25820d179341 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8452,6 +8452,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if isinstance(val, str) and val.strip(): token = val.strip() break + if not token: + config = getattr(adapter, "config", None) + val = getattr(config, "token", None) + if isinstance(val, str) and val.strip(): + token = val.strip() if not token: return None import hashlib diff --git a/tests/gateway/test_multiplex_adapter_registry.py b/tests/gateway/test_multiplex_adapter_registry.py index 7ecca64dfee0..43b89824cb5c 100644 --- a/tests/gateway/test_multiplex_adapter_registry.py +++ b/tests/gateway/test_multiplex_adapter_registry.py @@ -5,8 +5,9 @@ from gateway.run import GatewayRunner class _FakeAdapter: - def __init__(self, token=None): + def __init__(self, token=None, config=None): self.token = token + self.config = config class TestCredentialFingerprint: @@ -32,6 +33,17 @@ class TestCredentialFingerprint: self.bot_token = "alt-token" assert GatewayRunner._adapter_credential_fingerprint(_AltAdapter()) is not None + def test_reads_platform_config_token(self): + class _Config: + token = "config-token" + + fp = GatewayRunner._adapter_credential_fingerprint( + _FakeAdapter(token=None, config=_Config()) + ) + + assert fp is not None + assert "config-token" not in fp + class TestProfileMessageHandler: @pytest.mark.asyncio @@ -127,10 +139,57 @@ class TestPortBindingHardError: connected = await runner._start_one_profile_adapters("reviewer", "/tmp/x", {}) assert connected == 0 # nothing connected, but no MultiplexConfigError + @pytest.mark.asyncio + async def test_secondary_same_config_token_is_refused(self, monkeypatch): + """Adapters that keep their token on config still trip the mux guard.""" + from gateway.config import GatewayConfig, Platform, PlatformConfig + + class _ConfigTokenAdapter: + def __init__(self, token): + self.config = PlatformConfig(enabled=True, token=token) + self.disconnected = False + + async def connect(self): + raise AssertionError("duplicate adapter must not connect") + + async def disconnect(self): + self.disconnected = True + + runner = GatewayRunner.__new__(GatewayRunner) + runner.config = GatewayConfig(multiplex_profiles=True) + runner._profile_adapters = {} + + reviewer_cfg = GatewayConfig(multiplex_profiles=True) + reviewer_cfg.platforms = { + Platform.TELEGRAM: PlatformConfig(enabled=True, token="same-token"), + } + duplicate = _ConfigTokenAdapter("same-token") + claimed = { + ( + Platform.TELEGRAM, + GatewayRunner._adapter_credential_fingerprint( + _ConfigTokenAdapter("same-token") + ), + ): "default" + } + + monkeypatch.setattr( + "gateway.config.load_gateway_config", lambda: reviewer_cfg + ) + monkeypatch.setattr(runner, "_create_adapter", lambda p, c: duplicate) + monkeypatch.setattr(runner, "_adapter_disconnect_timeout_secs", lambda: 0) + + connected = await runner._start_one_profile_adapters( + "reviewer", "/tmp/x", claimed + ) + + assert connected == 0 + assert duplicate.disconnected is True + assert runner._profile_adapters["reviewer"] == {} + def test_port_binding_set_covers_known_listeners(self): from gateway.run import _PORT_BINDING_PLATFORM_VALUES # Every adapter that binds a TCP port must be in the guard set. for p in ("webhook", "api_server", "msgraph_webhook", "feishu", "wecom_callback", "bluebubbles", "sms"): assert p in _PORT_BINDING_PLATFORM_VALUES - From f1fde49e453e508b9dc49ec5b70694fbbf0279c0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:25:39 -0700 Subject: [PATCH 049/610] fix(gateway): avoid cross-profile session recovery (#59325) Co-authored-by: yoma --- gateway/session.py | 51 ++++++++++++++++++++ tests/gateway/test_multiplex_phase0.py | 64 ++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/gateway/session.py b/gateway/session.py index 9d20c204a83b..c0f631ffd2b5 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -1259,6 +1259,45 @@ class SessionStore: except Exception: return None + @staticmethod + def _profile_from_session_key(session_key: Optional[str]) -> Optional[str]: + """Extract the profile namespace encoded in a gateway session key.""" + if not session_key: + return None + parts = str(session_key).split(":") + if len(parts) < 2 or parts[0] != "agent": + return None + namespace = parts[1] or "main" + return "default" if namespace == "main" else namespace + + @staticmethod + def _active_profile_name() -> str: + try: + from hermes_cli.profiles import get_active_profile_name + return get_active_profile_name() or "default" + except Exception: + return "default" + + def _recovered_row_allowed_for_active_profile( + self, + *, + requested_session_key: str, + recovered: Dict[str, Any], + ) -> bool: + """Prevent non-multiplexed gateways from reviving another profile's row.""" + if getattr(self.config, "multiplex_profiles", False): + return True + + recovered_key = str(recovered.get("session_key") or "") + if not recovered_key or recovered_key == requested_session_key: + return True + + recovered_profile = self._profile_from_session_key(recovered_key) + if recovered_profile is None: + return True + + return recovered_profile == self._active_profile_name() + def _generate_session_key(self, source: SessionSource) -> str: """Generate a session key from a source.""" return build_session_key( @@ -1319,6 +1358,18 @@ class SessionStore: return None if not recovered: return None + if not self._recovered_row_allowed_for_active_profile( + requested_session_key=session_key, + recovered=recovered, + ): + logger.warning( + "Gateway session DB recovery ignored %s for %s because " + "multiplex_profiles is disabled and the row belongs to a " + "different profile", + recovered.get("session_key"), + session_key, + ) + return None try: self._db.reopen_session(str(recovered["id"])) except Exception as exc: diff --git a/tests/gateway/test_multiplex_phase0.py b/tests/gateway/test_multiplex_phase0.py index 798bca3a7d27..f7f5dbdc62d0 100644 --- a/tests/gateway/test_multiplex_phase0.py +++ b/tests/gateway/test_multiplex_phase0.py @@ -9,6 +9,7 @@ Covers the three Phase 0 deliverables: on. """ import pytest +from datetime import datetime from unittest.mock import patch import yaml @@ -201,3 +202,66 @@ class TestSessionStoreProfileResolution: with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"): assert store._generate_session_key(s) == "agent:main:telegram:dm:99" + +class _RecoveringDB: + def __init__(self, row): + self.row = row + self.reopened = [] + + def find_latest_gateway_session_for_peer(self, **_kwargs): + return self.row + + def reopen_session(self, session_id): + self.reopened.append(session_id) + + +class TestSessionStoreUnmultiplexedRecovery: + """Turning multiplexing off must not recover another profile's session.""" + + def _store_with_row(self, tmp_path, row, **cfg_kw): + config = GatewayConfig(**cfg_kw) + with patch("gateway.session.SessionStore._ensure_loaded"): + store = SessionStore(sessions_dir=tmp_path, config=config) + store._db = _RecoveringDB(row) + store._loaded = True + return store + + def test_flag_off_rejects_other_profile_peer_fallback(self, tmp_path): + row = { + "id": "sess-coder", + "started_at": 1700000000, + "session_key": "agent:coder:telegram:dm:99", + } + store = self._store_with_row(tmp_path, row) + source = _src(chat_id="99", chat_type="dm") + + with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"): + recovered = store._recover_session_from_db( + session_key="agent:main:telegram:dm:99", + source=source, + now=datetime.fromtimestamp(1700000001), + ) + + assert recovered is None + assert store._db.reopened == [] + + def test_flag_off_allows_active_profile_peer_fallback(self, tmp_path): + row = { + "id": "sess-coder", + "started_at": 1700000000, + "session_key": "agent:coder:telegram:dm:99", + } + store = self._store_with_row(tmp_path, row) + source = _src(chat_id="99", chat_type="dm") + + with patch("hermes_cli.profiles.get_active_profile_name", return_value="coder"): + recovered = store._recover_session_from_db( + session_key="agent:main:telegram:dm:99", + source=source, + now=datetime.fromtimestamp(1700000001), + ) + + assert recovered is not None + assert recovered.session_id == "sess-coder" + assert recovered.session_key == "agent:main:telegram:dm:99" + assert store._db.reopened == ["sess-coder"] From 088b98944286e3a775b09562596fceeffb8d7927 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:25:45 -0700 Subject: [PATCH 050/610] fix(gateway): scope reset banners' session info to the serving profile (#59048 salvage) (#59329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gateway): scope reset banners' session info to the serving profile The auto-reset notice and the manual /reset //new banner both appended _format_session_info() outside any profile scope, so a multiplexed gateway advertised the base config's model/provider/context while the session actually ran on the profile's. Route both call sites through a new _reset_notice_session_info(source), which enters _profile_runtime_scope for the source's profile when gateway.multiplex_profiles is on (mirroring _run_agent's gating), so _load_gateway_config()/_resolve_gateway_model() resolve the profile's config.yaml via the existing context-local home override. Single-profile gateways never enter the scope — behavior unchanged. Both call sites invoke the helper via asyncio.to_thread: under the scope, resolution can do blocking work (credential refresh, context-length HTTP probes) that previously failed fast unscoped and must not run on the event loop. Fixes #59003 Co-Authored-By: Claude Fable 5 * chore(release): map irresi author email for PR #59048 salvage --------- Co-authored-by: irresi Co-authored-by: Claude Fable 5 --- gateway/run.py | 24 ++++++++++++++- gateway/slash_commands.py | 8 +++-- scripts/release.py | 1 + tests/gateway/test_session_info.py | 48 ++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 25820d179341..6162f10b6fbe 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10678,7 +10678,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew f"Adjust reset timing in config.yaml under session_reset." ) try: - session_info = self._format_session_info() + session_info = await asyncio.to_thread( + self._reset_notice_session_info, source + ) if session_info: notice = f"{notice}\n\n{session_info}" except Exception: @@ -11927,6 +11929,26 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # Restore session context variables to their pre-handler state self._clear_session_env(_session_env_tokens) + def _reset_notice_session_info(self, source: SessionSource) -> str: + """Session-info block for the auto-reset notice, profile-scoped. + + When multiplexing, resolve model/provider/context inside the profile + serving ``source`` — otherwise the banner advertises the base config's + model while the session actually runs on the profile's (#59003). + Mirrors ``_run_agent``'s gating so single-profile gateways never + enter the scope. + + Call via ``asyncio.to_thread`` from async handlers: under the scope, + resolution can do blocking work (credential refresh, context-length + HTTP probes) that must not run on the event loop. The scope is entered + inside this method, so contextvars behave correctly in the worker + thread. + """ + if getattr(getattr(self, "config", None), "multiplex_profiles", False): + with _profile_runtime_scope(self._resolve_profile_home_for_source(source)): + return self._format_session_info() + return self._format_session_info() + def _format_session_info(self) -> str: """Resolve current model config and return a formatted info block. diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 14687e07dde0..a299e8e9b35d 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -231,9 +231,13 @@ class GatewaySlashCommandsMixin: "session_key": session_key, }) - # Resolve session config info to surface to the user + # Resolve session config info to surface to the user, scoped to the + # profile serving this source so a multiplexed /reset //new banner + # reports the profile's model, not the base config's (#59003). try: - session_info = self._format_session_info() + session_info = await asyncio.to_thread( + self._reset_notice_session_info, source + ) except Exception: session_info = "" diff --git a/scripts/release.py b/scripts/release.py index 4dcc16f4d256..d14604a0f8ca 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -52,6 +52,7 @@ AUTHOR_MAP = { "AndreasHiltner@users.noreply.github.com": "AndreasHiltner", # PR #56854 salvage (gateway: route multiplex profile responses through the profile's own adapter — 53-site _adapter_for_source sweep) "allenliang2022@users.noreply.github.com": "allenliang2022", # PR #56932 test coverage folded into #56909 salvage (408 → retryable timeout) "m888.braun@hotmail.com": "ManniBr", # PR #57417 partial salvage (gateway: fail-closed adapter resolution for unregistered secondary profiles) + "blueirobin02@gmail.com": "irresi", # PR #59048 salvage (gateway: scope reset banners' session info to the serving profile; #59003) "jashlee+microsoft@microsoft.com": "s905060", # PR #57943 salvage (photon: auto-reinstall stale sidecar node_modules when lockfile is newer than npm's install marker; #59169) "lohinth25@proton.me": "l0h1nth", # PR #32210 salvage (mattermost: accept leading-space slash commands from mobile clients; #25184) "perkintahmaz50@gmail.com": "devatnull", # PR #58704 salvage (whatsapp: native Baileys polls, clarify-as-poll, location pins, structured quoted replies, PTT/audio split, bridge_helpers extraction) diff --git a/tests/gateway/test_session_info.py b/tests/gateway/test_session_info.py index ec05b31b7359..5f93a3820aae 100644 --- a/tests/gateway/test_session_info.py +++ b/tests/gateway/test_session_info.py @@ -107,3 +107,51 @@ class TestFormatSessionInfo: info = runner._format_session_info() assert "4K" in info assert "config" in info + + +class TestResetNoticeSessionInfo: + """#59003: the auto-reset banner must report the serving profile's config, + not the multiplexer's base config.""" + + _RUNTIME = {"provider": "", "base_url": "", "api_key": ""} + + def _source(self): + from gateway.config import Platform + from gateway.session import SessionSource + return SessionSource( + platform=Platform.TELEGRAM, chat_id="123", user_id="u1", + profile="planner", + ) + + def _homes(self, tmp_path): + base = tmp_path / "base" + profile = tmp_path / "profiles" / "planner" + profile.mkdir(parents=True) + base.mkdir() + base.joinpath("config.yaml").write_text( + "model:\n default: base-model\n provider: custom\n context_length: 1000\n") + profile.joinpath("config.yaml").write_text( + "model:\n default: profile-model\n provider: anthropic\n context_length: 2000\n") + return base, profile + + def test_multiplex_uses_profile_config(self, runner, tmp_path): + from types import SimpleNamespace + base, profile = self._homes(tmp_path) + runner.config = SimpleNamespace(multiplex_profiles=True) + with patch("gateway.run._hermes_home", base), \ + patch.object(GatewayRunner, "_resolve_profile_home_for_source", return_value=profile), \ + patch("gateway.run._resolve_runtime_agent_kwargs", return_value=self._RUNTIME): + info = runner._reset_notice_session_info(self._source()) + assert "profile-model" in info + assert "anthropic" in info + assert "base-model" not in info + + def test_single_profile_uses_base_config(self, runner, tmp_path): + from types import SimpleNamespace + base, _profile = self._homes(tmp_path) + runner.config = SimpleNamespace(multiplex_profiles=False) + with patch("gateway.run._hermes_home", base), \ + patch("gateway.run._resolve_runtime_agent_kwargs", return_value=self._RUNTIME): + info = runner._reset_notice_session_info(self._source()) + assert "base-model" in info + assert "profile-model" not in info From 249c69b9586567d54dc7bcdeadd221f49aa2d304 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:25:52 -0700 Subject: [PATCH 051/610] fix(gateway): per-profile pairing whitelist isolation in multiplex mode (#53045 salvage) (#59330) * fix(gateway): per-profile pairing whitelist isolation for multiplex gateways Pairing approvals are stored per profile (profiles//pairing/) and authz routes pairing checks through the serving profile's store, so one profile's approved users no longer authorize against every other profile's whitelist in multiplex mode. The global store remains for the hermes pairing CLI and single-profile gateways; unregistered/unstamped sources fall back to it, preserving existing behavior. Salvaged from PR #53045 (pairing half). The SOUL.md half was dropped: the agent turn already runs inside _profile_runtime_scope on main, so load_soul_md() resolves per-profile without changes. Original work by @soddy022. * ci: redispatch after arm64 docker dashboard-slot flake (unrelated to this PR) --------- Co-authored-by: soddy022 <290613374+soddy022@users.noreply.github.com> --- gateway/authz_mixin.py | 23 +++++++- gateway/pairing.py | 29 +++++++-- gateway/run.py | 15 ++++- tests/gateway/test_pairing.py | 107 ++++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 8 deletions(-) diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index f84ca259ccba..166635df59a2 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -246,6 +246,21 @@ class GatewayAuthorizationMixin: return any(str(item).strip() for item in sender_allow) return False + def _pairing_store_for(self, source: "SessionSource"): + """Pick the per-profile PairingStore for a source, falling back to global. + + In a multiplexing gateway, each profile owns its own pairing whitelist + so isolation is preserved. When the source has no profile (single- + profile gateway, or a path that hasn't stamped profile yet) or the + profile isn't registered, fall back to ``self.pairing_store`` (the + global default) so existing behavior is preserved. + """ + per_profile = getattr(self, "pairing_stores", None) or {} + profile = getattr(source, "profile", None) + if profile and profile in per_profile: + return per_profile[profile] + return getattr(self, "pairing_store", None) + def _is_user_authorized(self, source: SessionSource) -> bool: """ Check if a user is authorized to use the bot. @@ -430,10 +445,14 @@ class GatewayAuthorizationMixin: # allowlist IS configured, operator approval also writes the user into # that allowlist (see PairingStore._approve_user), keeping a single # operator-visible source of truth. (#23778: the original bypass was the - # inbound message/approval-button gate, not this grant; that gate is + # inbound message/approval-button gate, not this gate; that gate is # fixed separately.) + # In multiplex gateways, route to the per-profile PairingStore so each + # profile's whitelist is isolated; falls back to the global store when + # the source has no profile or the profile isn't registered. platform_name = source.platform.value if source.platform else "" - if self.pairing_store.is_approved(platform_name, user_id): + pairing_store = self._pairing_store_for(source) + if pairing_store is not None and pairing_store.is_approved(platform_name, user_id): return True # Check platform-specific and global allowlists diff --git a/gateway/pairing.py b/gateway/pairing.py index c7d3a8c7440c..4e3712c117b1 100644 --- a/gateway/pairing.py +++ b/gateway/pairing.py @@ -195,22 +195,41 @@ class PairingStore: - {platform}-pending.json : pending pairing requests - {platform}-approved.json : approved (paired) users - _rate_limits.json : rate limit tracking + + When constructed with ``profile=""``, storage lives under + ``/profiles//pairing/`` (per-profile, used by + multiplexing gateways so each profile has its own whitelist). + Without a profile, storage is the global ``/pairing/`` + directory (backward-compat for the ``hermes pairing`` CLI). """ - def __init__(self): - PAIRING_DIR.mkdir(parents=True, exist_ok=True) + def __init__(self, profile: Optional[str] = None): + # Resolve storage directory lazily — tests use a temp HERMES_HOME + # and PairingStore may be constructed before the env is set. + if profile: + from hermes_constants import get_hermes_home + self._dir = get_hermes_home() / "profiles" / profile / "pairing" + else: + self._dir = PAIRING_DIR + self._dir.mkdir(parents=True, exist_ok=True) # Protects all read-modify-write cycles. The gateway runs multiple # platform adapters concurrently in threads sharing one PairingStore. self._lock = threading.RLock() + self._profile = profile # for diagnostics / log lines + + @property + def profile(self) -> Optional[str]: + """Profile name this store is scoped to, or None for the global store.""" + return self._profile def _pending_path(self, platform: str) -> Path: - return PAIRING_DIR / f"{platform}-pending.json" + return self._dir / f"{platform}-pending.json" def _approved_path(self, platform: str) -> Path: - return PAIRING_DIR / f"{platform}-approved.json" + return self._dir / f"{platform}-approved.json" def _rate_limit_path(self) -> Path: - return PAIRING_DIR / "_rate_limits.json" + return self._dir / "_rate_limits.json" def _load_json(self, path: Path) -> dict: if path.exists(): diff --git a/gateway/run.py b/gateway/run.py index 6162f10b6fbe..4541d6465759 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3047,9 +3047,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception as exc: logger.debug("checkpoint auto-maintenance skipped: %s", exc) - # DM pairing store for code-based user authorization + # DM pairing store for code-based user authorization. + # ``pairing_store`` stays as the global/default store for the + # ``hermes pairing`` CLI and any caller without a profile context. + # ``pairing_stores`` is the per-profile map used by + # ``authz_mixin._is_user_authorized`` to route checks to the right + # whitelist (one per profile in multiplex mode). from gateway.pairing import PairingStore self.pairing_store = PairingStore() + self.pairing_stores: Dict[str, "PairingStore"] = {} # Event hook system from gateway.hooks import HookRegistry @@ -8335,6 +8341,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew try: from gateway.status import write_runtime_status served = [active] + sorted(self._profile_adapters.keys()) + # Per-profile PairingStores so authz_mixin can route pairing + # checks to the right whitelist. The active profile gets a store + # at its HERMES_HOME; additional served profiles get one under + # profiles//pairing/. See gateway.pairing.PairingStore. + for name in served: + if name and name not in self.pairing_stores: + self.pairing_stores[name] = PairingStore(profile=name) write_runtime_status(served_profiles=served) except Exception: logger.debug("could not record served_profiles", exc_info=True) diff --git a/tests/gateway/test_pairing.py b/tests/gateway/test_pairing.py index c08ac2055770..aa4a9b1e9f48 100644 --- a/tests/gateway/test_pairing.py +++ b/tests/gateway/test_pairing.py @@ -720,3 +720,110 @@ class TestUnreadablePairingFile: # The warning must fire — otherwise this is the silent-failure bug. assert any(rec.levelno == logging.WARNING for rec in caplog.records), \ "PermissionError on approved.json must produce a WARNING log line" +# Profile-scoped storage (multiplexing gateway isolation) +# --------------------------------------------------------------------------- + + +class TestProfileScopedStorage: + """PairingStore(profile="") should isolate per-profile whitelists + under /profiles//pairing/ so a multiplexing gateway + can keep each profile's allowlist separate. + """ + + def test_default_store_uses_global_dir(self, tmp_path, monkeypatch): + """PairingStore() (no profile) keeps the legacy global path so the + ``hermes pairing`` CLI continues to work without a profile context.""" + from hermes_constants import get_hermes_home + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + # Re-import PAIRING_DIR (it's a module-level constant resolved at + # import time) so the test exercises the right path. We patch it + # rather than re-importing so the assertion is unambiguous. + with patch("gateway.pairing.PAIRING_DIR", tmp_path): + store = PairingStore() + assert store.profile is None + assert store._dir == tmp_path + assert store._approved_path("weixin") == tmp_path / "weixin-approved.json" + + def test_profile_store_uses_profiles_subdir(self, tmp_path, monkeypatch): + """PairingStore(profile="yangyang") puts files under + /profiles/yangyang/pairing/.""" + from hermes_constants import get_hermes_home + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + store = PairingStore(profile="yangyang") + assert store.profile == "yangyang" + expected = tmp_path / "profiles" / "yangyang" / "pairing" + assert store._dir == expected + assert store._approved_path("weixin") == expected / "weixin-approved.json" + # Auto-creates the directory + assert expected.is_dir() + + def test_profile_approval_does_not_leak_to_global(self, tmp_path, monkeypatch): + """Approving in a profile-scoped store must not appear in the global + store — and vice versa. This is the whole point of the fix.""" + from hermes_constants import get_hermes_home + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + with patch("gateway.pairing.PAIRING_DIR", tmp_path): + global_store = PairingStore() + profile_store = PairingStore(profile="yangyang") + + # Approve in the profile store + profile_store._approve_user("weixin", "yangyang_user", "杨洋") + # And in the global store, a different user + global_store._approve_user("weixin", "global_user", "Default") + + # Cross-isolation: each store only sees its own user + assert profile_store.is_approved("weixin", "yangyang_user") is True + assert profile_store.is_approved("weixin", "global_user") is False + assert global_store.is_approved("weixin", "global_user") is True + assert global_store.is_approved("weixin", "yangyang_user") is False + + def test_profile_uses_distinct_rate_limit_file(self, tmp_path, monkeypatch): + """Rate-limit state is per-profile, not shared globally — otherwise + one profile's flood would lock out the other profile's users.""" + from hermes_constants import get_hermes_home + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + with patch("gateway.pairing.PAIRING_DIR", tmp_path): + global_store = PairingStore() + profile_store = PairingStore(profile="yangyang") + + assert global_store._rate_limit_path() == tmp_path / "_rate_limits.json" + assert profile_store._rate_limit_path() == ( + tmp_path / "profiles" / "yangyang" / "pairing" / "_rate_limits.json" + ) + + def test_pairing_store_for_helper_routes_by_profile(self, tmp_path, monkeypatch): + """_pairing_store_for(source) on a gateway-like object picks the + per-profile store when source.profile is set, and falls back to + the global store when it isn't (defensive — single-profile + gateways, or any code path that hasn't stamped source.profile).""" + from gateway.session import SessionSource + from gateway.config import Platform + + class FakeGateway: + def __init__(self): + self.pairing_store = object() # sentinel + self.pairing_stores = { + "default": "default-store", + "yangyang": "yangyang-store", + } + + # Method under test — copy of the real helper so this test + # is self-contained even if the real one moves. + def _pairing_store_for(self, source): + per_profile = getattr(self, "pairing_stores", None) or {} + profile = getattr(source, "profile", None) + if profile and profile in per_profile: + return per_profile[profile] + return getattr(self, "pairing_store", None) + + g = FakeGateway() + # source with profile="yangyang" → per-profile store + s_yy = SessionSource(platform=Platform.WEIXIN, chat_id="c", profile="yangyang") + assert g._pairing_store_for(s_yy) == "yangyang-store" + # source with no profile → fallback to global + s_none = SessionSource(platform=Platform.WEIXIN, chat_id="c") + assert g._pairing_store_for(s_none) is g.pairing_store + # source with an unknown profile → fallback (defensive) + s_unknown = SessionSource(platform=Platform.WEIXIN, chat_id="c", profile="ghost") + assert g._pairing_store_for(s_unknown) is g.pairing_store + From 76979a086971cf688303f8a9267c503b623af80a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:33:06 -0700 Subject: [PATCH 052/610] fix(auth): per-profile Anthropic OAuth file + complete port-binding platform set (#57563 salvage) (#59339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(auth): resolve Anthropic OAuth file per-profile + close port-binding platform gaps Two focused pieces salvaged from PR #57563: 1. _HERMES_OAUTH_FILE was computed at module import time — frozen before HERMES_HOME/profile overrides, so multiplexed profile turns read and wrote the DEFAULT profile's .anthropic_oauth.json (OAuth path hijack). Replaced with a lazy _get_hermes_oauth_file(); all web_server.py call sites updated. 2. _PORT_BINDING_PLATFORM_VALUES was missing whatsapp_cloud and line — both bind aiohttp TCP listeners, so a secondary multiplex profile enabling them would collide with the primary's listener instead of failing fast at startup. Original work by @austinlaw076. The rest of #57563 was redundant on main (adapter routing sweep superseded by #56854's salvage; cron secret scope landed in fdab380a1; nested-config fallback in from_dict). * chore(release): map austinlaw076 author email for PR #57563 salvage * test(hermes_cli): patch _get_hermes_oauth_file instead of removed _HERMES_OAUTH_FILE constant --------- Co-authored-by: Austin Co-authored-by: Ben --- agent/anthropic_adapter.py | 8 +++--- gateway/run.py | 2 ++ hermes_cli/web_server.py | 26 ++++++++++--------- scripts/release.py | 1 + .../hermes_cli/test_web_server_oauth_write.py | 2 +- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 535f8db88487..623560250df4 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1390,7 +1390,8 @@ _OAUTH_TOKEN_URL = _OAUTH_TOKEN_URLS[0] _OAUTH_TOKEN_USER_AGENT = "axios/1.7.9" _OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback" _OAUTH_SCOPES = "org:create_api_key user:profile user:inference" -_HERMES_OAUTH_FILE = get_hermes_home() / ".anthropic_oauth.json" +def _get_hermes_oauth_file() -> Path: + return get_hermes_home() / ".anthropic_oauth.json" def _generate_pkce() -> tuple: @@ -1538,9 +1539,10 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]: def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]: """Read Hermes-managed OAuth credentials from ~/.hermes/.anthropic_oauth.json.""" - if _HERMES_OAUTH_FILE.exists(): + oauth_file = _get_hermes_oauth_file() + if oauth_file.exists(): try: - data = json.loads(_HERMES_OAUTH_FILE.read_text(encoding="utf-8")) + data = json.loads(oauth_file.read_text(encoding="utf-8")) if data.get("accessToken"): return data except (json.JSONDecodeError, OSError, IOError) as e: diff --git a/gateway/run.py b/gateway/run.py index 4541d6465759..0754a994c14a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1363,6 +1363,8 @@ _PORT_BINDING_PLATFORM_VALUES = frozenset({ "wecom_callback", "bluebubbles", "sms", + "whatsapp_cloud", + "line", }) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 3da2c370b8a4..06306f02fe5a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -6443,11 +6443,11 @@ def _anthropic_oauth_status() -> Dict[str, Any]: try: from agent.anthropic_adapter import ( read_hermes_oauth_credentials, - _HERMES_OAUTH_FILE, + _get_hermes_oauth_file, ) except ImportError: read_hermes_oauth_credentials = None # type: ignore - _HERMES_OAUTH_FILE = None # type: ignore + _get_hermes_oauth_file = None # type: ignore hermes_creds = None if read_hermes_oauth_credentials: @@ -6459,7 +6459,7 @@ def _anthropic_oauth_status() -> Dict[str, Any]: return { "logged_in": True, "source": "hermes_pkce", - "source_label": f"Hermes PKCE ({_HERMES_OAUTH_FILE})", + "source_label": f"Hermes PKCE ({_get_hermes_oauth_file() if _get_hermes_oauth_file else None})", "token_preview": _truncate_token(hermes_creds.get("accessToken")), "expires_at": hermes_creds.get("expiresAt"), "has_refresh_token": bool(hermes_creds.get("refreshToken")), @@ -6896,9 +6896,10 @@ async def disconnect_oauth_provider( if provider_id == "anthropic": cleared = False try: - from agent.anthropic_adapter import _HERMES_OAUTH_FILE - if _HERMES_OAUTH_FILE.exists(): - _HERMES_OAUTH_FILE.unlink() + from agent.anthropic_adapter import _get_hermes_oauth_file + oauth_file = _get_hermes_oauth_file() + if oauth_file.exists(): + oauth_file.unlink() cleared = True except Exception: pass @@ -7042,24 +7043,25 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a Mirrors what auth_commands.add_command does so the dashboard flow leaves the system in the same state as ``hermes auth add anthropic``. """ - from agent.anthropic_adapter import _HERMES_OAUTH_FILE + from agent.anthropic_adapter import _get_hermes_oauth_file + oauth_file = _get_hermes_oauth_file() payload = { "accessToken": access_token, "refreshToken": refresh_token, "expiresAt": expires_at_ms, } - _HERMES_OAUTH_FILE.parent.mkdir(parents=True, exist_ok=True) - tmp_path = _HERMES_OAUTH_FILE.with_name( - f"{_HERMES_OAUTH_FILE.name}.tmp.{os.getpid()}.{secrets.token_hex(8)}" + oauth_file.parent.mkdir(parents=True, exist_ok=True) + tmp_path = oauth_file.with_name( + f"{oauth_file.name}.tmp.{os.getpid()}.{secrets.token_hex(8)}" ) try: with tmp_path.open("w", encoding="utf-8") as handle: handle.write(json.dumps(payload, indent=2)) handle.flush() os.fsync(handle.fileno()) - os.replace(tmp_path, _HERMES_OAUTH_FILE) + os.replace(tmp_path, oauth_file) try: - _HERMES_OAUTH_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR) + oauth_file.chmod(stat.S_IRUSR | stat.S_IWUSR) except OSError: pass finally: diff --git a/scripts/release.py b/scripts/release.py index d14604a0f8ca..a4c8e2c6a085 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -52,6 +52,7 @@ AUTHOR_MAP = { "AndreasHiltner@users.noreply.github.com": "AndreasHiltner", # PR #56854 salvage (gateway: route multiplex profile responses through the profile's own adapter — 53-site _adapter_for_source sweep) "allenliang2022@users.noreply.github.com": "allenliang2022", # PR #56932 test coverage folded into #56909 salvage (408 → retryable timeout) "m888.braun@hotmail.com": "ManniBr", # PR #57417 partial salvage (gateway: fail-closed adapter resolution for unregistered secondary profiles) + "austin@openvm067.space": "austinlaw076", # PR #57563 partial salvage (auth: lazy per-profile Anthropic OAuth file; gateway: whatsapp_cloud/line added to port-binding platform set) "blueirobin02@gmail.com": "irresi", # PR #59048 salvage (gateway: scope reset banners' session info to the serving profile; #59003) "jashlee+microsoft@microsoft.com": "s905060", # PR #57943 salvage (photon: auto-reinstall stale sidecar node_modules when lockfile is newer than npm's install marker; #59169) "lohinth25@proton.me": "l0h1nth", # PR #32210 salvage (mattermost: accept leading-space slash commands from mobile clients; #25184) diff --git a/tests/hermes_cli/test_web_server_oauth_write.py b/tests/hermes_cli/test_web_server_oauth_write.py index 0ef49fb2bc4c..20200b9a73b2 100644 --- a/tests/hermes_cli/test_web_server_oauth_write.py +++ b/tests/hermes_cli/test_web_server_oauth_write.py @@ -19,7 +19,7 @@ class _DummyPool: @pytest.fixture def oauth_file(monkeypatch, tmp_path): target = tmp_path / '.anthropic_oauth.json' - monkeypatch.setattr('agent.anthropic_adapter._HERMES_OAUTH_FILE', target) + monkeypatch.setattr('agent.anthropic_adapter._get_hermes_oauth_file', lambda: target) monkeypatch.setattr('agent.credential_pool.load_pool', lambda _provider: _DummyPool()) return target From 4b9d9b205bf44cded07906a67c6c8bbea64d4f73 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Tue, 7 Jul 2026 18:33:28 +1000 Subject: [PATCH 053/610] fix(dashboard): use loopback host for in-container WebSocket client (#58993) [salvage #59682] (#60092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): use loopback host for in-container WebSocket client (#58993) Fixes #58993 - the in-container Dashboard's WebSocket client was dialing the bind host (0.0.0.0) instead of 127.0.0.1, hijacking the host browser when the container port was exposed. * `hermes_cli/web_server.py::resolve_dashboard_ws_url()` now substitutes 127.0.0.1 for any 0.0.0.0 bind host discovered via the existing `find_unused_port` / `get_listen_address` path. LAN IPs and explicit `DASHBOARD_WS_HOST` overrides pass through unchanged. * Existing tests preserved (no regression on the explicit-bind case). Tests in `tests/dashboard/test_ws_client_host.py` cover: - Bind host 0.0.0.0 → ws URL uses 127.0.0.1 - Bind host 127.0.0.1 → ws URL uses 127.0.0.1 (no regression) - Bind host 192.168.1.5 → ws URL preserves the LAN IP - DASHBOARD_WS_HOST env override wins over auto-detection AI-assisted fix by https://github.com/SquabbyZ/peaks-loop (cherry picked from commit 5501dd38d660619729ebcad85b75a8f46f8deb38) * chore(release): map SquabbyZ email for AUTHOR_MAP attribution (#59682) --------- Co-authored-by: SquabbyZ <601709253@qq.com> --- hermes_cli/web_server.py | 42 +++- scripts/release.py | 1 + tests/dashboard/test_ws_client_host.py | 319 +++++++++++++++++++++++++ 3 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 tests/dashboard/test_ws_client_host.py diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 06306f02fe5a..8232ab77c0a5 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -12879,6 +12879,44 @@ def _resolve_chat_argv( return list(argv), str(cwd) if cwd else None, env +# Hosts that mean "listen on every interface" — the server should bind to +# them, but an in-container client must NOT dial them: dialing 0.0.0.0 +# resolves to "any local interface", which on most platforms routes through +# the kernel's wildcard stack and behind a forward proxy (HTTPS_PROXY with +# a NO_PROXY that doesn't list 0.0.0.0) gets MITM'd into a failed handshake +# (issue #58993). The fix is to use a loopback address for the client +# netloc while leaving the bind host alone. +_WILDCARD_HOSTS = frozenset({"0.0.0.0", "::"}) + + +def _resolve_client_ws_host() -> Optional[str]: + """Return the host the in-container WS client should dial. + + Resolution order: + + 1. Explicit ``HERMES_DASHBOARD_WS_HOST`` env var — wins always. Operators + running the dashboard behind a forward proxy can pin a routable host + (e.g. ``127.0.0.1``, the container's internal IP, or a sidecar DNS + name) and bypass auto-detection entirely. + 2. The configured bind host — if it's a wildcard (``0.0.0.0`` / ``::``), + substitute ``127.0.0.1`` since both the dashboard and its TUI child + run in the same container. + 3. Any other bind host (loopback or LAN IP) — preserved verbatim. + """ + explicit = os.environ.get("HERMES_DASHBOARD_WS_HOST", "").strip() + if explicit: + return explicit + + host = getattr(app.state, "bound_host", None) + if not host: + return None + + if host in _WILDCARD_HOSTS: + return "127.0.0.1" + + return host + + def _build_gateway_ws_url() -> Optional[str]: """ws:// URL the PTY child should attach to for JSON-RPC gateway traffic. @@ -12890,7 +12928,7 @@ def _build_gateway_ws_url() -> Optional[str]: the child reads this URL once at startup and reuses it on every reconnect, and a 30s-TTL ticket can expire before a slow cold boot even dials. """ - host = getattr(app.state, "bound_host", None) + host = _resolve_client_ws_host() port = getattr(app.state, "bound_port", None) if not host or not port: @@ -12957,7 +12995,7 @@ def _build_sidecar_url(channel: str) -> Optional[str]: Connections authenticated this way are recorded under the ``server-internal`` identity in the audit log. """ - host = getattr(app.state, "bound_host", None) + host = _resolve_client_ws_host() port = getattr(app.state, "bound_port", None) if not host or not port: diff --git a/scripts/release.py b/scripts/release.py index a4c8e2c6a085..5537aafdb840 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1904,6 +1904,7 @@ AUTHOR_MAP = { "yosapol@jitrak.dev": "Eji4h", # direct email match "kiljadn@gmail.com": "designnotdrum", # PR #56480 salvage (toolset static-inference fix) "lavya@loom.local": "LavyaTandel", # PR #57893 salvage local git identity (envelope-layout cache markers on tool/empty-assistant messages; #57845) + "601709253@qq.com": "SquabbyZ", # PR #59682 salvage (in-container dashboard WS loopback host; #58993) } diff --git a/tests/dashboard/test_ws_client_host.py b/tests/dashboard/test_ws_client_host.py new file mode 100644 index 000000000000..609941fb6408 --- /dev/null +++ b/tests/dashboard/test_ws_client_host.py @@ -0,0 +1,319 @@ +"""Regression tests for the in-container WebSocket client host resolution. + +Issue #58993: when the dashboard binds to a wildcard (``0.0.0.0`` / ``::``), +the in-container WS clients built by ``_build_gateway_ws_url`` and +``_build_sidecar_url`` used the bind host verbatim, so the child TUI +dialed ``ws://0.0.0.0:9119/api/ws``. Behind a forward proxy whose +``NO_PROXY`` does not list ``0.0.0.0`` that wildcard dial is routed through +the proxy and fails the handshake. + +The contract these tests pin down: + + * Wildcard bind (``0.0.0.0`` / ``::``) → client dials ``127.0.0.1``. + * Loopback bind (``127.0.0.1``) → client dials ``127.0.0.1`` (unchanged). + * LAN / non-wildcard bind (``192.168.1.5``) → client dials that exact + address (no rewrite to loopback — the bind was deliberate). + * Explicit ``HERMES_DASHBOARD_WS_HOST`` env var → wins always, regardless + of the bind host. + * ``app.state.bound_host`` is left untouched — the bind address used by + the listener doesn't change. +""" + +from __future__ import annotations + +import os + +import pytest + +from hermes_cli import web_server + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def saved_app_state(): + """Snapshot and restore the bits of ``app.state`` the tests mutate. + + The dashboard's WS URL builders read three things off ``app.state``: + ``bound_host``, ``bound_port``, ``auth_required``. We capture them so + the suite doesn't leak state into other tests, then yield control. + """ + saved = { + "bound_host": getattr(web_server.app.state, "bound_host", None), + "bound_port": getattr(web_server.app.state, "bound_port", None), + "auth_required": getattr(web_server.app.state, "auth_required", None), + } + yield saved + for key, value in saved.items(): + setattr(web_server.app.state, key, value) + + +@pytest.fixture +def clear_ws_host_env(monkeypatch): + """Ensure no ``HERMES_DASHBOARD_WS_HOST`` leaks in from the test shell.""" + monkeypatch.delenv("HERMES_DASHBOARD_WS_HOST", raising=False) + yield monkeypatch + + +def _set_bound(saved_app_state, host: str, port: int = 9119): + web_server.app.state.bound_host = host + web_server.app.state.bound_port = port + web_server.app.state.auth_required = False + + +def _netloc(ws_url: str) -> str: + """Pull the ``host:port`` (or ``[host]:port``) segment out of a ws URL.""" + assert ws_url is not None, "expected a URL, got None" + # ws://host:port/path?qs — strip the scheme, then take netloc up to "/". + after_scheme = ws_url.split("://", 1)[1] + netloc = after_scheme.split("/", 1)[0] + return netloc + + +# --------------------------------------------------------------------------- +# _resolve_client_ws_host — direct unit tests +# --------------------------------------------------------------------------- + + +class TestResolveClientWsHost: + def test_wildcard_ipv4_uses_loopback(self, saved_app_state, clear_ws_host_env): + _set_bound(saved_app_state, "0.0.0.0") + assert web_server._resolve_client_ws_host() == "127.0.0.1" + + def test_wildcard_ipv6_uses_loopback(self, saved_app_state, clear_ws_host_env): + _set_bound(saved_app_state, "::") + assert web_server._resolve_client_ws_host() == "127.0.0.1" + + def test_loopback_bind_unchanged(self, saved_app_state, clear_ws_host_env): + _set_bound(saved_app_state, "127.0.0.1") + assert web_server._resolve_client_ws_host() == "127.0.0.1" + + def test_lan_bind_preserved(self, saved_app_state, clear_ws_host_env): + """A non-loopback, non-wildcard bind must NOT be rewritten — the + operator chose that address deliberately (e.g. bridge networking in + a sidecar topology) and rewriting it to 127.0.0.1 would break their + setup.""" + _set_bound(saved_app_state, "192.168.1.5") + assert web_server._resolve_client_ws_host() == "192.168.1.5" + + def test_public_dns_bind_preserved(self, saved_app_state, clear_ws_host_env): + _set_bound(saved_app_state, "fly-app.example.dev") + assert web_server._resolve_client_ws_host() == "fly-app.example.dev" + + def test_explicit_env_wins_over_wildcard( + self, saved_app_state, monkeypatch + ): + monkeypatch.setenv("HERMES_DASHBOARD_WS_HOST", "10.0.0.7") + _set_bound(saved_app_state, "0.0.0.0") + assert web_server._resolve_client_ws_host() == "10.0.0.7" + + def test_explicit_env_wins_over_lan_bind( + self, saved_app_state, monkeypatch + ): + """Even when the bind is a routable address, the explicit override + still wins — operators may want to bypass the bind address + altogether (e.g. to dial a different sidecar replica).""" + monkeypatch.setenv("HERMES_DASHBOARD_WS_HOST", "10.0.0.7") + _set_bound(saved_app_state, "192.168.1.5") + assert web_server._resolve_client_ws_host() == "10.0.0.7" + + def test_explicit_env_wins_over_loopback( + self, saved_app_state, monkeypatch + ): + monkeypatch.setenv("HERMES_DASHBOARD_WS_HOST", "10.0.0.7") + _set_bound(saved_app_state, "127.0.0.1") + assert web_server._resolve_client_ws_host() == "10.0.0.7" + + def test_blank_env_falls_back_to_bind( + self, saved_app_state, monkeypatch + ): + """An explicitly empty override (e.g. ``HERMES_DASHBOARD_WS_HOST=``) + must NOT silently pin to loopback — it's an unset-by-accident, not + an intent. Treat whitespace-only as absent and fall through.""" + monkeypatch.setenv("HERMES_DASHBOARD_WS_HOST", " ") + _set_bound(saved_app_state, "0.0.0.0") + assert web_server._resolve_client_ws_host() == "127.0.0.1" + + def test_no_bound_host_returns_none( + self, saved_app_state, clear_ws_host_env + ): + web_server.app.state.bound_host = None + web_server.app.state.bound_port = None + assert web_server._resolve_client_ws_host() is None + + def test_bind_host_unchanged_after_wildcard_resolution( + self, saved_app_state, clear_ws_host_env + ): + """Resolution only affects the client netloc — ``bound_host`` on + ``app.state`` (used by the listener and host-header middleware) is + NOT mutated.""" + _set_bound(saved_app_state, "0.0.0.0") + web_server._resolve_client_ws_host() + assert web_server.app.state.bound_host == "0.0.0.0" + + +# --------------------------------------------------------------------------- +# _build_gateway_ws_url — end-to-end URL contract +# --------------------------------------------------------------------------- + + +class TestGatewayWsUrlHost: + def test_wildcard_bind_dials_loopback( + self, saved_app_state, clear_ws_host_env + ): + _set_bound(saved_app_state, "0.0.0.0", port=9119) + url = web_server._build_gateway_ws_url() + assert url is not None + # ws://127.0.0.1:9119/api/ws?token=… + assert url.startswith("ws://127.0.0.1:9119/api/ws") + assert "0.0.0.0" not in url + + def test_ipv6_wildcard_bind_dials_loopback( + self, saved_app_state, clear_ws_host_env + ): + _set_bound(saved_app_state, "::", port=9119) + url = web_server._build_gateway_ws_url() + assert url is not None + assert url.startswith("ws://127.0.0.1:9119/api/ws") + # The ``::`` must not leak into the client URL. + assert "::" not in url + + def test_loopback_bind_uses_loopback( + self, saved_app_state, clear_ws_host_env + ): + _set_bound(saved_app_state, "127.0.0.1", port=8080) + url = web_server._build_gateway_ws_url() + assert url is not None + assert url.startswith("ws://127.0.0.1:8080/api/ws") + + def test_lan_bind_preserved( + self, saved_app_state, clear_ws_host_env + ): + _set_bound(saved_app_state, "192.168.1.5", port=9120) + url = web_server._build_gateway_ws_url() + assert url is not None + assert url.startswith("ws://192.168.1.5:9120/api/ws") + + def test_explicit_env_overrides_wildcard( + self, saved_app_state, monkeypatch + ): + monkeypatch.setenv("HERMES_DASHBOARD_WS_HOST", "10.0.0.7") + _set_bound(saved_app_state, "0.0.0.0", port=9119) + url = web_server._build_gateway_ws_url() + assert url is not None + assert url.startswith("ws://10.0.0.7:9119/api/ws") + assert "0.0.0.0" not in url + + def test_explicit_env_overrides_lan( + self, saved_app_state, monkeypatch + ): + monkeypatch.setenv("HERMES_DASHBOARD_WS_HOST", "10.0.0.7") + _set_bound(saved_app_state, "192.168.1.5", port=9120) + url = web_server._build_gateway_ws_url() + assert url is not None + assert url.startswith("ws://10.0.0.7:9120/api/ws") + + def test_wildcard_keeps_query_string( + self, saved_app_state, clear_ws_host_env + ): + """Regression-guard: rewriting the host must not drop the + ``?token=`` or ``?internal=`` credential.""" + _set_bound(saved_app_state, "0.0.0.0", port=9119) + url = web_server._build_gateway_ws_url() + assert url is not None + assert "?" in url + # Loopback / ``--insecure`` path uses the session token. + assert f"token={web_server._SESSION_TOKEN}" in url + + def test_no_bound_host_returns_none( + self, saved_app_state, clear_ws_host_env + ): + web_server.app.state.bound_host = None + web_server.app.state.bound_port = None + assert web_server._build_gateway_ws_url() is None + + +# --------------------------------------------------------------------------- +# _build_sidecar_url — end-to-end URL contract +# --------------------------------------------------------------------------- + + +class TestSidecarUrlHost: + def test_wildcard_bind_dials_loopback( + self, saved_app_state, clear_ws_host_env + ): + _set_bound(saved_app_state, "0.0.0.0", port=9119) + url = web_server._build_sidecar_url("ch-1") + assert url is not None + assert url.startswith("ws://127.0.0.1:9119/api/pub") + assert "0.0.0.0" not in url + assert "channel=ch-1" in url + + def test_ipv6_wildcard_bind_dials_loopback( + self, saved_app_state, clear_ws_host_env + ): + _set_bound(saved_app_state, "::", port=9119) + url = web_server._build_sidecar_url("ch-1") + assert url is not None + assert url.startswith("ws://127.0.0.1:9119/api/pub") + assert "::" not in url + + def test_loopback_bind_uses_loopback( + self, saved_app_state, clear_ws_host_env + ): + _set_bound(saved_app_state, "127.0.0.1", port=8080) + url = web_server._build_sidecar_url("ch-1") + assert url is not None + assert url.startswith("ws://127.0.0.1:8080/api/pub") + + def test_lan_bind_preserved( + self, saved_app_state, clear_ws_host_env + ): + _set_bound(saved_app_state, "192.168.1.5", port=9120) + url = web_server._build_sidecar_url("ch-1") + assert url is not None + assert url.startswith("ws://192.168.1.5:9120/api/pub") + + def test_explicit_env_overrides_wildcard( + self, saved_app_state, monkeypatch + ): + monkeypatch.setenv("HERMES_DASHBOARD_WS_HOST", "10.0.0.7") + _set_bound(saved_app_state, "0.0.0.0", port=9119) + url = web_server._build_sidecar_url("ch-1") + assert url is not None + assert url.startswith("ws://10.0.0.7:9119/api/pub") + assert "0.0.0.0" not in url + + def test_explicit_env_overrides_lan( + self, saved_app_state, monkeypatch + ): + monkeypatch.setenv("HERMES_DASHBOARD_WS_HOST", "10.0.0.7") + _set_bound(saved_app_state, "192.168.1.5", port=9120) + url = web_server._build_sidecar_url("ch-1") + assert url is not None + assert url.startswith("ws://10.0.0.7:9120/api/pub") + + def test_no_bound_host_returns_none( + self, saved_app_state, clear_ws_host_env + ): + web_server.app.state.bound_host = None + web_server.app.state.bound_port = None + assert web_server._build_sidecar_url("ch-1") is None + + +# --------------------------------------------------------------------------- +# _netloc helper is exposed only because it's useful for tests; if the +# production code ever changes the URL shape the tests catch the regression +# above without needing to assert on the full string. +# --------------------------------------------------------------------------- + + +def test_netloc_helper_handles_ipv6_bracket_form(): + """The IPv6 netloc path is exercised by the production ``[host]:port`` + branch when ``HERMES_DASHBOARD_WS_HOST`` points at an IPv6 address. + Verify the helper doesn't choke on the bracket form.""" + assert _netloc("ws://[::1]:9119/api/ws?x=1") == "[::1]:9119" + assert _netloc("ws://127.0.0.1:9119/api/ws?x=1") == "127.0.0.1:9119" \ No newline at end of file From 043e71f1f46b7e1067d706cb85c6a49fd6c3484f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:05:21 -0700 Subject: [PATCH 054/610] fix(gateway): use process-level HERMES_HOME for identity files (#56993 salvage) (#59341) * fix(gateway): use process-level HERMES_HOME for identity files Gateway identity files (PID, lock, runtime status, takeover/stop markers) were written via get_hermes_home() which honours the _HERMES_HOME_OVERRIDE contextvar used for per-session profile dispatch. When a profile-context task happened to be active at write time, files landed in the wrong profile directory. Add _get_process_hermes_home() that skips the contextvar and uses only the HERMES_HOME env var or platform default, and route all gateway identity file paths through it. Fixes #56986 * chore(release): map liuhao1024 author email for PR #56993 salvage --------- Co-authored-by: liuhao1024 Co-authored-by: Ben --- gateway/status.py | 30 +++++++++++++++++++++++------- scripts/release.py | 1 + tests/gateway/test_status.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/gateway/status.py b/gateway/status.py index 9b8a1b6f83c2..d041193fdf09 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -20,7 +20,7 @@ import subprocess import sys from datetime import datetime, timezone from pathlib import Path -from hermes_constants import get_hermes_home +from hermes_constants import get_hermes_home, _get_platform_default_hermes_home from typing import Any, Optional from utils import atomic_json_write @@ -42,9 +42,25 @@ _gateway_lock_handle = None _WINDOWS_LOCK_OFFSET = 1024 * 1024 +def _get_process_hermes_home() -> Path: + """Return the process-level HERMES_HOME, skipping context-local overrides. + + Gateway identity files (PID, lock, runtime status, takeover/stop markers) + must always live in the directory the gateway process was launched with. + ``get_hermes_home()`` honors ``_HERMES_HOME_OVERRIDE`` contextvar used for + per-session profile dispatch, which would route these files into the wrong + profile directory when a profile-context task happens to be active at write + time. See issue #56986. + """ + val = os.environ.get("HERMES_HOME", "").strip() + if val: + return Path(val) + return _get_platform_default_hermes_home() + + def _get_pid_path() -> Path: """Return the path to the gateway PID file, respecting HERMES_HOME.""" - home = get_hermes_home() + home = _get_process_hermes_home() return home / "gateway.pid" @@ -52,7 +68,7 @@ def _get_gateway_lock_path(pid_path: Optional[Path] = None) -> Path: """Return the path to the runtime gateway lock file.""" if pid_path is not None: return pid_path.with_name(_GATEWAY_LOCK_FILENAME) - home = get_hermes_home() + home = _get_process_hermes_home() return home / _GATEWAY_LOCK_FILENAME @@ -1136,13 +1152,13 @@ _PLANNED_STOP_MARKER_TTL_S = 60 def _get_takeover_marker_path() -> Path: """Return the path to the --replace takeover marker file.""" - home = get_hermes_home() + home = _get_process_hermes_home() return home / _TAKEOVER_MARKER_FILENAME def _get_planned_stop_marker_path() -> Path: """Return the path to the intentional gateway stop marker file.""" - home = get_hermes_home() + home = _get_process_hermes_home() return home / _PLANNED_STOP_MARKER_FILENAME @@ -1197,7 +1213,7 @@ def _consume_pid_marker_for_self( # unaffected. Leave a mismatched marker in place so the correct # profile can still consume it. replacer_home = record.get("replacer_hermes_home") - if replacer_home is not None and replacer_home != str(get_hermes_home()): + if replacer_home is not None and replacer_home != str(_get_process_hermes_home()): return False our_pid = os.getpid() @@ -1246,7 +1262,7 @@ def write_takeover_marker(target_pid: int) -> bool: "target_pid": target_pid, "target_start_time": target_start_time, "replacer_pid": os.getpid(), - "replacer_hermes_home": str(get_hermes_home()), + "replacer_hermes_home": str(_get_process_hermes_home()), "written_at": _utc_now_iso(), } _write_json_file(_get_takeover_marker_path(), record) diff --git a/scripts/release.py b/scripts/release.py index 5537aafdb840..ba098a9f10fc 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -53,6 +53,7 @@ AUTHOR_MAP = { "allenliang2022@users.noreply.github.com": "allenliang2022", # PR #56932 test coverage folded into #56909 salvage (408 → retryable timeout) "m888.braun@hotmail.com": "ManniBr", # PR #57417 partial salvage (gateway: fail-closed adapter resolution for unregistered secondary profiles) "austin@openvm067.space": "austinlaw076", # PR #57563 partial salvage (auth: lazy per-profile Anthropic OAuth file; gateway: whatsapp_cloud/line added to port-binding platform set) + "sunsky.lau@gmail.com": "liuhao1024", # PR #56993 salvage (gateway: process-level HERMES_HOME for pid/lock/status identity files; #56986) "blueirobin02@gmail.com": "irresi", # PR #59048 salvage (gateway: scope reset banners' session info to the serving profile; #59003) "jashlee+microsoft@microsoft.com": "s905060", # PR #57943 salvage (photon: auto-reinstall stale sidecar node_modules when lockfile is newer than npm's install marker; #59169) "lohinth25@proton.me": "l0h1nth", # PR #32210 salvage (mattermost: accept leading-space slash commands from mobile clients; #25184) diff --git a/tests/gateway/test_status.py b/tests/gateway/test_status.py index ab4c94157437..780bf8efc516 100644 --- a/tests/gateway/test_status.py +++ b/tests/gateway/test_status.py @@ -293,6 +293,41 @@ class TestGatewayPidState: finally: status.release_gateway_runtime_lock() + def test_gateway_identity_files_use_process_home_not_context_override( + self, tmp_path, monkeypatch + ): + """Regression: pid/lock/state files must use process-level HERMES_HOME. + + When a profile context override is active (e.g., during session dispatch + for a named profile), gateway identity files should still be written to + the process-level HERMES_HOME, not the profile's directory. See #56986. + """ + from hermes_constants import set_hermes_home_override, reset_hermes_home_override + + process_home = tmp_path / "default" + process_home.mkdir() + profile_home = tmp_path / "profiles" / "cfo" + profile_home.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(process_home)) + + # Simulate a profile context override being active during write. + token = set_hermes_home_override(str(profile_home)) + try: + status.write_pid_file() + finally: + reset_hermes_home_override(token) + + # PID file must land in the process-level home, not the profile home. + assert (process_home / "gateway.pid").exists() + assert not (profile_home / "gateway.pid").exists() + + payload = json.loads((process_home / "gateway.pid").read_text()) + assert payload["pid"] == os.getpid() + + # Cleanup for atexit hooks. + monkeypatch.setenv("HERMES_HOME", str(process_home)) + (process_home / "gateway.pid").unlink(missing_ok=True) + class TestGatewayRuntimeStatus: def test_write_json_file_uses_atomic_json_write(self, tmp_path, monkeypatch): From ae878e1aeeaf8241d8c88c0fa8d3b2e25728950c Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Tue, 7 Jul 2026 03:06:40 +0700 Subject: [PATCH 055/610] fix(state): set active=1 explicitly in message INSERTs (#51646) append_message() and _insert_message_rows() relied on the schema DEFAULT for messages.active. Legacy databases that gained the column via ALTER TABLE without a working INSERT default can store active=NULL, which makes get_messages_as_conversation()'s active=1 filter drop every gateway turn. --- hermes_state.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index a2895b09c7a2..0a506c85a499 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -3465,8 +3465,8 @@ class SessionDB: """INSERT INTO messages (session_id, role, content, tool_call_id, tool_calls, tool_name, timestamp, token_count, finish_reason, reasoning, reasoning_content, reasoning_details, codex_reasoning_items, - codex_message_items, platform_message_id, observed) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + codex_message_items, platform_message_id, observed, active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( session_id, role, @@ -3484,6 +3484,7 @@ class SessionDB: codex_message_items_json, platform_message_id, 1 if observed else 0, + 1, ), ) msg_id = cursor.lastrowid @@ -3556,8 +3557,8 @@ class SessionDB: """INSERT INTO messages (session_id, role, content, tool_call_id, tool_calls, tool_name, timestamp, token_count, finish_reason, reasoning, reasoning_content, reasoning_details, codex_reasoning_items, - codex_message_items, platform_message_id, observed) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + codex_message_items, platform_message_id, observed, active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( session_id, role, @@ -3575,6 +3576,7 @@ class SessionDB: codex_message_items_json, platform_msg_id, 1 if msg.get("observed") else 0, + 1, ), ) inserted += 1 From 7445df150512b5fe5eb165fef828c7d1badeec58 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Tue, 7 Jul 2026 03:06:40 +0700 Subject: [PATCH 056/610] test(state): cover explicit active=1 on message INSERT (#51646) Add a regression for legacy DBs whose active column has no INSERT default, and assert gateway conversation replay sees newly appended rows. --- tests/test_hermes_state.py | 54 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 707272f10045..8e0231b4c416 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -663,6 +663,60 @@ class TestMessageStorage: assert messages[0]["content"] == "Hello" assert messages[1]["role"] == "assistant" + def test_append_message_sets_active_for_transcript_loader(self, db): + """Regression #51646: gateway loaders filter on active = 1.""" + db.create_session(session_id="s1", source="discord") + mid = db.append_message("s1", role="user", content="Hello") + active = db._conn.execute( + "SELECT active FROM messages WHERE id = ?", (mid,) + ).fetchone()[0] + assert active == 1 + assert len(db.get_messages_as_conversation("s1")) == 1 + + def test_append_message_active_one_when_column_has_no_default(self, tmp_path): + """Legacy DBs may have active added without a working INSERT default.""" + db_path = tmp_path / "legacy_state.db" + conn = sqlite3.connect(db_path) + conn.executescript( + """ + CREATE TABLE schema_version (version INTEGER); + INSERT INTO schema_version VALUES (11); + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, source TEXT, started_at REAL, ended_at REAL, + message_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0, + title TEXT, parent_session_id TEXT, model_config TEXT + ); + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, + tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, + timestamp REAL NOT NULL, token_count INTEGER, finish_reason TEXT, + reasoning TEXT, reasoning_content TEXT, reasoning_details TEXT, + codex_reasoning_items TEXT, codex_message_items TEXT, + platform_message_id TEXT, observed INTEGER DEFAULT 0 + ); + CREATE TABLE state_meta (key TEXT PRIMARY KEY, value TEXT); + """ + ) + conn.execute( + "INSERT INTO sessions (id, source, started_at) VALUES ('s1', 'discord', 1.0)" + ) + conn.execute("ALTER TABLE messages ADD COLUMN active INTEGER") + conn.execute("ALTER TABLE messages ADD COLUMN compacted INTEGER DEFAULT 0") + conn.commit() + conn.close() + + session_db = SessionDB(db_path=db_path) + try: + mid = session_db.append_message("s1", role="user", content="gateway turn") + active = session_db._conn.execute( + "SELECT active FROM messages WHERE id = ?", (mid,) + ).fetchone()[0] + assert active == 1 + assert len(session_db.get_messages_as_conversation("s1")) == 1 + finally: + session_db.close() + def test_append_message_accepts_explicit_timestamp(self, db): db.create_session(session_id="s1", source="telegram") event_ts = 1777383653.0 From b75783e6dc3ae881b49062a61d2e4fe3fc8fd36c Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:25:56 +0530 Subject: [PATCH 057/610] fix(state): heal NULL active rows on every startup, not just pre-v12 DBs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repair UPDATE ('SET active = 1 WHERE active IS NULL') was gated at schema_version < 12, so already-v12+ databases — the exact population hit by #51646, where the reconciler-added active column lacks its NOT NULL DEFAULT 1 — never healed rows written as NULL by the pre-fix INSERTs. Move the idempotent repair into unconditional startup so historical gateway transcripts become visible again after upgrading. Follow-up to the salvage of #59832 by @HexLab98. --- hermes_state.py | 29 +++++++++++--------- tests/test_hermes_state.py | 54 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 0a506c85a499..6e1300f5de40 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1381,6 +1381,23 @@ class SessionDB: # column (idx_messages_session_active) — same ordering constraint. cursor.executescript(DEFERRED_INDEX_SQL) + # Heal NULL ``active`` rows unconditionally on every startup. + # On real-world DBs the reconciler-added ``active`` column can lack + # its NOT NULL DEFAULT 1 (older reconciler builds reconstructed the + # type without the default — see #51646: PRAGMA shows + # (17,'active','INTEGER',0,None,0) in the wild), so INSERTs that + # omitted the column wrote NULL and the ``WHERE active = 1`` + # transcript loaders hid the whole history. The INSERTs now set + # active=1 explicitly; this idempotent repair un-hides rows written + # before the fix. It was previously gated at ``current_version < + # 12`` which never re-ran for already-v12+ databases. + try: + cursor.execute( + "UPDATE messages SET active = 1 WHERE active IS NULL" + ) + except sqlite3.OperationalError: + pass + fts5_available = self._sqlite_supports_fts5(cursor) fts_migrations_complete = True if not fts5_available: @@ -1495,18 +1512,6 @@ class SessionDB: fts_migrations_complete = False else: fts_migrations_complete = False - if current_version < 12: - # v12: messages.active flag for rewind/undo soft-deletion. - # The declarative reconcile_columns() above adds the - # column itself; this UPDATE is belt-and-suspenders to - # ensure any rows that pre-existed the ADD COLUMN have - # active=1 rather than NULL. - try: - cursor.execute( - "UPDATE messages SET active = 1 WHERE active IS NULL" - ) - except sqlite3.OperationalError: - pass if current_version < 16: # v16: tag delegate subagent rows so pickers stay clean after # parent deletes that used to orphan them (parent_session_id → NULL). diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 8e0231b4c416..4ad888442afc 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -717,6 +717,60 @@ class TestMessageStorage: finally: session_db.close() + def test_startup_heals_null_active_rows(self, tmp_path): + """Rows written as active=NULL before the fix are un-hidden on startup. + + The repair UPDATE used to be gated at schema_version < 12, so + already-v12+ databases (the exact population hit by #51646) never + healed their historical NULL rows. It now runs on every startup. + """ + db_path = tmp_path / "legacy_state.db" + conn = sqlite3.connect(db_path) + conn.executescript( + """ + CREATE TABLE schema_version (version INTEGER); + INSERT INTO schema_version VALUES (12); + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, source TEXT, started_at REAL, ended_at REAL, + message_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0, + title TEXT, parent_session_id TEXT, model_config TEXT + ); + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, + tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, + timestamp REAL NOT NULL, token_count INTEGER, finish_reason TEXT, + reasoning TEXT, reasoning_content TEXT, reasoning_details TEXT, + codex_reasoning_items TEXT, codex_message_items TEXT, + platform_message_id TEXT, observed INTEGER DEFAULT 0 + ); + CREATE TABLE state_meta (key TEXT PRIMARY KEY, value TEXT); + """ + ) + # Default-less active column, as seen in the wild (#51646 PRAGMA). + conn.execute("ALTER TABLE messages ADD COLUMN active INTEGER") + conn.execute("ALTER TABLE messages ADD COLUMN compacted INTEGER DEFAULT 0") + conn.execute( + "INSERT INTO sessions (id, source, started_at) VALUES ('s1', 'discord', 1.0)" + ) + # A row written by the pre-fix INSERT: active is NULL. + conn.execute( + "INSERT INTO messages (session_id, role, content, timestamp) " + "VALUES ('s1', 'user', 'old hidden turn', 1.0)" + ) + conn.commit() + conn.close() + + session_db = SessionDB(db_path=db_path) + try: + active = session_db._conn.execute( + "SELECT active FROM messages WHERE content = 'old hidden turn'" + ).fetchone()[0] + assert active == 1 + assert len(session_db.get_messages_as_conversation("s1")) == 1 + finally: + session_db.close() + def test_append_message_accepts_explicit_timestamp(self, db): db.create_session(session_id="s1", source="telegram") event_ts = 1777383653.0 From 11516f3cc34ddb5d6809c93d593ffb53c5a421ff Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:33:01 +0530 Subject: [PATCH 058/610] perf: partial index so the startup NULL-active repair skips the table scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: EXPLAIN QUERY PLAN showed the unconditional repair UPDATE doing SCAN messages (~75-135ms on 500k rows, every startup) — the existing idx_messages_session_active can't serve an active-only predicate. A partial index on (active) WHERE active IS NULL costs near-zero storage on healthy DBs (no NULL rows) and drops the 0-match repair to ~0.04ms. Lives in DEFERRED_INDEX_SQL because it references the reconciler-added column. --- hermes_state.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hermes_state.py b/hermes_state.py index 6e1300f5de40..3702623aac46 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -802,6 +802,8 @@ CREATE INDEX IF NOT EXISTS idx_compression_locks_expires ON compression_locks(ex DEFERRED_INDEX_SQL = """ CREATE INDEX IF NOT EXISTS idx_messages_session_active ON messages(session_id, active, timestamp); +CREATE INDEX IF NOT EXISTS idx_messages_active_null + ON messages(active) WHERE active IS NULL; CREATE INDEX IF NOT EXISTS idx_sessions_session_key ON sessions(session_key, started_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_gateway_peer From 33a529538d1607ff1552e6eaf526cf95e3dfff49 Mon Sep 17 00:00:00 2001 From: knoal <114367649+knoal@users.noreply.github.com> Date: Mon, 6 Jul 2026 06:27:52 -0700 Subject: [PATCH 059/610] fix(gateway): strip stale dangerous-confirmation text in user messages (#59607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a high-risk side effect (e.g. host restart via shutdown.exe) runs, the user's plain-text confirmation phrase is persisted in the conversation transcript. If the host restart killed the gateway process before the assistant's tool result was written, the transcript tail ends on the assistant's text response - and the dangerous confirmation text remains in the user role. On the next inbound message - possibly a casual 'are you there?' from the user minutes later - the LLM sees the stale confirmation and may interpret the new turn as a fresh re-confirmation, re-executing the destructive action. This is the failure mode reported in #59607. Fix: - Add strip_stale_dangerous_confirmations() in agent/replay_cleanup.py that removes user messages whose content matches a known dangerous confirmation pattern AND whose timestamp is older than 60 seconds. - Add is_dangerous_confirmation() helper with the matched patterns (i18n-aware: covers 確認強制重開機 from the original incident). - Wire the stripper into _build_gateway_agent_history() right after the existing 75ed07ace strippers, so the strip chain is: strip_interrupted_tool_tails -> strip_dangling_tool_call_tail -> strip_stale_dangerous_confirmations. - Update _build_replay_entry() to preserve the timestamp on user messages (it was previously dropped), since the new stripper needs it. Complements 75ed07ace (which strips the assistant side of the broken tail) by handling the user side: a stale plain-text confirmation that the assistant has not yet responded to in a way the resume logic recognises. Failing-test-first discipline: the bug-detection test test_stale_confirmation_text_is_stripped_on_resume fails on unfixed code (proves the test catches the bug) and passes after the fix. Five additional safety tests confirm no regression on: - fresh confirmations (within expiry) are preserved - non-confirmation text is preserved - non-matching histories are untouched - dangerous-pattern detection works in all cases (case, i18n, None) - direct unit test of the strip helper Refs: #59607 --- agent/replay_cleanup.py | 100 +++++++++ gateway/run.py | 40 +++- .../gateway/test_stale_confirmation_expiry.py | 191 ++++++++++++++++++ 3 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 tests/gateway/test_stale_confirmation_expiry.py diff --git a/agent/replay_cleanup.py b/agent/replay_cleanup.py index 12de7a5c7e9e..767b13f66050 100644 --- a/agent/replay_cleanup.py +++ b/agent/replay_cleanup.py @@ -138,3 +138,103 @@ def sanitize_replay_history( if not agent_history: return agent_history return strip_dangling_tool_call_tail(strip_interrupted_tool_tails(agent_history)) + + +# ────────────────────────────────────────────────────────────────────── +# Stale dangerous-confirmation text expiry (#59607) +# ────────────────────────────────────────────────────────────────────── + +# How long a high-risk confirmation phrase remains valid. +# Short on purpose: dangerous side effects should not survive any restart +# or session resumption gap. The user can always re-confirm if needed. +_DANGEROUS_CONFIRMATION_EXPIRY_SECONDS = 60.0 + +# Confirmation phrases that unlock destructive host actions. +# Substring match (case-insensitive) so that user variants (e.g. trailing +# punctuation, additional context) still match. Add new patterns here when +# new high-risk actions are introduced. +_DANGEROUS_CONFIRMATION_PATTERNS: tuple = ( + "confirm forced restart", + "confirm forced reboot", + "confirm shutdown", + "confirm reboot", + "confirm power off", + "yes, delete everything", + "confirm wipe", + "confirm factory reset", + # i18n variants observed in the original incident + "確認強制重開機", + "確認強制重開", + "確認重啟", +) + + +def is_dangerous_confirmation(content: Any) -> bool: + """Return True if a user-message text matches a known dangerous confirmation. + + Used by ``strip_stale_dangerous_confirmations`` to decide which + transcript rows to expire. Substring + case-insensitive so that + ``"Please confirm forced restart, the host is critical"`` still matches. + """ + if not isinstance(content, str): + return False + text = content.strip().lower() + return any(pattern in text for pattern in _DANGEROUS_CONFIRMATION_PATTERNS) + + +def strip_stale_dangerous_confirmations( + agent_history: List[Dict[str, Any]], + *, + now: float, + expiry_seconds: float = _DANGEROUS_CONFIRMATION_EXPIRY_SECONDS, +) -> List[Dict[str, Any]]: + """Strip stale dangerous-confirmation text in user messages (#59607). + + When a high-risk side effect (e.g. host restart via ``shutdown.exe``) + runs, the user's plain-text confirmation phrase is persisted in the + conversation transcript. If the host restart killed the gateway + process before the assistant's tool result was written, the + transcript tail ends on the assistant's text response — and the + dangerous confirmation text remains in the user role. + + On the next inbound message — possibly a casual "are you there?" from + the user minutes later — the LLM sees the stale confirmation and may + interpret the new turn as a fresh re-confirmation, re-executing the + destructive action. This is the failure mode reported in #59607. + + This function strips user messages whose content matches a known + dangerous confirmation pattern *and* whose timestamp is older than + ``expiry_seconds`` (default: 60s). Messages without a timestamp are + left untouched (backward compatibility: legacy transcripts and + in-memory test scaffolding have no timestamps). User messages that + contain dangerous confirmation text but are within the expiry window + are also left untouched — they represent a fresh confirmation that + has not yet been acted on. + + Complements 75ed07ace (which strips the *assistant* side of the + broken tail) by handling the *user* side: a stale plain-text + confirmation that the assistant has not yet responded to in a way + the resume logic recognises. + """ + if not agent_history: + return agent_history + + cleaned: List[Dict[str, Any]] = [] + for msg in agent_history: + if ( + isinstance(msg, dict) + and msg.get("role") == "user" + and is_dangerous_confirmation(msg.get("content", "")) + ): + ts = msg.get("timestamp") + if ts is not None and (now - float(ts)) > expiry_seconds: + logger.debug( + "Stripping stale dangerous-confirmation text from user " + "message (age=%.1fs, expiry=%.1fs): %r", + now - float(ts), + expiry_seconds, + (msg.get("content") or "")[:80], + ) + continue + cleaned.append(msg) + return cleaned diff --git a/gateway/run.py b/gateway/run.py index 0754a994c14a..d47b11ebb770 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -722,7 +722,12 @@ _ASSISTANT_REPLAY_FIELDS: tuple[str, ...] = ( ) -def _build_replay_entry(role: str, content: Any, msg: Dict[str, Any]) -> Dict[str, Any]: +def _build_replay_entry( + role: str, + content: Any, + msg: Dict[str, Any], + preserve_timestamp: bool = False, +) -> Dict[str, Any]: """Build a replay entry for a non-tool-calling message, preserving the assistant fields the agent's API builders rely on for multi-turn fidelity. @@ -730,13 +735,20 @@ def _build_replay_entry(role: str, content: Any, msg: Dict[str, Any]) -> Dict[st be unit-tested in isolation. Mirrors the ``_ASSISTANT_REPLAY_FIELDS`` contract above. + ``preserve_timestamp``: when True, copy the source row's ``timestamp`` + onto the replay entry. Currently only user messages need this — the + stale-dangerous-confirmation stripper in ``agent/replay_cleanup.py`` + reads the timestamp to decide whether a confirmation is too old to + replay safely. Assistant/tool messages are not timestamp-stripped in + the same way, so we keep the existing default of dropping it. + Empty values: most fields are dropped when falsy (matching the original PR #2974 behaviour) since an empty list/string for those carries no information. The exception is ``reasoning_content``: DeepSeek/Kimi thinking-mode replay treats an empty string as a meaningful sentinel that ``_copy_reasoning_content_for_api`` upgrades to a single space. - Dropping it here would make the gateway send no ``reasoning_content`` at - all on the next turn, which can cause HTTP 400 from strict thinking + Dropping it here would make the gateway send no ``reasoning_content`` + at all on the next turn, which can cause HTTP 400 from strict thinking providers. """ entry: Dict[str, Any] = {"role": role, "content": content} @@ -752,6 +764,10 @@ def _build_replay_entry(role: str, content: Any, msg: Dict[str, Any]) -> Dict[st elif not _rval: continue entry[_rkey] = _rval + if preserve_timestamp: + ts = msg.get("timestamp") + if ts: + entry["timestamp"] = ts return entry @@ -867,7 +883,12 @@ def _build_gateway_agent_history( if msg.get("mirror"): mirror_src = msg.get("mirror_source", "another session") content = f"[Delivered from {mirror_src}] {content}" - entry = _build_replay_entry(role, content, msg) + # Preserve the timestamp on user messages so the + # stale-dangerous-confirmation stripper in agent/replay_cleanup.py + # can read it. The timestamp is dropped from assistant messages + # because they don't need it; the replay-tail strippers look at + # assistant(tool_calls), not timestamps. + entry = _build_replay_entry(role, content, msg, preserve_timestamp=(role == "user")) agent_history.append(entry) # Strip interrupted tool-call tails so the LLM doesn't re-execute @@ -881,6 +902,15 @@ def _build_gateway_agent_history( # on resume and loops the restart forever (#49201). agent_history = _strip_dangling_tool_call_tail(agent_history) + # Strip stale dangerous-confirmation text in user messages (#59607). + # A high-risk confirmation phrase (e.g. "confirm forced restart") that + # is older than the expiry window must not be replayed to the model, + # otherwise an unrelated follow-up message can be interpreted as a + # fresh confirmation and trigger the destructive action a second time. + agent_history = _strip_stale_dangerous_confirmations( + agent_history, now=time.time() + ) + observed_context = "\n".join(observed_group_context).strip() or None return agent_history, observed_context @@ -978,6 +1008,8 @@ from agent.replay_cleanup import ( # noqa: E402 is_interrupted_tool_result as _is_interrupted_tool_result, strip_interrupted_tool_tails as _strip_interrupted_tool_tails, strip_dangling_tool_call_tail as _strip_dangling_tool_call_tail, + strip_stale_dangerous_confirmations as _strip_stale_dangerous_confirmations, + is_dangerous_confirmation as _is_dangerous_confirmation, ) diff --git a/tests/gateway/test_stale_confirmation_expiry.py b/tests/gateway/test_stale_confirmation_expiry.py new file mode 100644 index 000000000000..431b6ba91172 --- /dev/null +++ b/tests/gateway/test_stale_confirmation_expiry.py @@ -0,0 +1,191 @@ +"""Tests for stale confirmation text expiry in gateway history. + +Reproduces the failure mode in #59607: when a host-restart-causing tool +runs, the user confirmation text remains in conversation history. On +resume, the LLM sees the confirmation at the tail and may re-execute the +destructive action if the user sends any follow-up. + +The fix: high-risk confirmation messages (user messages containing a +known dangerous confirmation pattern) are tagged with a creation +timestamp when they enter conversation history. On history replay, if +the confirmation is older than EXPIRY (60s by default), the confirmation +text is stripped from agent history before the model sees it. + +This complements 75ed07ace (which handles dangling assistant tool_calls +tail) by handling the user-side confirmation tail. +""" + +import time +import pytest +from typing import Dict, List + +from gateway.run import ( + _build_gateway_agent_history, + _is_dangerous_confirmation, + _strip_stale_dangerous_confirmations, +) + + +# High-risk confirmation patterns. A user message matching one of these +# (case-insensitive) is considered a "confirmation text" and is subject +# to the expiry rule. Add new patterns here as new high-risk side effects +# are introduced. +def test_dangerous_confirmation_helper(): + """The pattern matcher is case-insensitive and substring-based.""" + assert _is_dangerous_confirmation("confirm forced restart") + assert _is_dangerous_confirmation("CONFIRM FORCED RESTART") + assert _is_dangerous_confirmation(" confirm forced restart please ") + assert _is_dangerous_confirmation("I want to confirm forced restart the server") + + # i18n + assert _is_dangerous_confirmation("確認強制重開機") + + # Not a confirmation + assert not _is_dangerous_confirmation("can you restart the docker container?") + assert not _is_dangerous_confirmation("hello world") + assert not _is_dangerous_confirmation("") + assert not _is_dangerous_confirmation(None) + assert not _is_dangerous_confirmation(123) + + +def _make_history_with_confirmation( + *, + user_message_at: float, + assistant_warning_at: float, + confirmation_message: str, + confirmation_at: float, + assistant_action_at: float, +) -> List[Dict]: + """Build a synthetic conversation history with a confirmation text. + + Uses the real gateway's "timestamp" field (epoch seconds, as set in + gateway/run.py:11616, 11650, 11692, etc). + """ + return [ + {"role": "user", "content": "can you force a restart?", "timestamp": user_message_at}, + {"role": "assistant", "content": "Rebooting the host is dangerous. To confirm, please type: 'confirm forced restart'", "timestamp": assistant_warning_at}, + {"role": "user", "content": confirmation_message, "timestamp": confirmation_at}, + {"role": "assistant", "content": "OK, restarting now.", "timestamp": assistant_action_at}, + ] + + +def test_stale_confirmation_text_is_stripped_on_resume(): + """A confirmation text older than EXPIRY (60s by default) is stripped. + + This is the core fix for #59607. + """ + current_time = time.time() + # User confirmation was 5 minutes ago — well past the 60s expiry + user_message_at = current_time - 1000 + assistant_warning_at = current_time - 999 + confirmation_at = current_time - 300 # 5 minutes ago + assistant_action_at = current_time - 299 + + history = _make_history_with_confirmation( + user_message_at=user_message_at, + assistant_warning_at=assistant_warning_at, + confirmation_message="confirm forced restart", + confirmation_at=confirmation_at, + assistant_action_at=assistant_action_at, + ) + + agent_history, _ = _build_gateway_agent_history(history) + + # The stale confirmation text should be gone + confirmation_present = any( + m.get("role") == "user" and "confirm forced restart" in (m.get("content") or "") + for m in agent_history + ) + assert not confirmation_present, ( + f"Stale confirmation text should be stripped on resume. " + f"Got agent_history: {agent_history}" + ) + + +def test_fresh_confirmation_text_is_preserved(): + """A confirmation text within EXPIRY is kept (not yet expired).""" + current_time = time.time() + user_message_at = current_time - 30 + assistant_warning_at = current_time - 29 + confirmation_at = current_time - 5 # 5 seconds ago — fresh + assistant_action_at = current_time - 4 + + history = _make_history_with_confirmation( + user_message_at=user_message_at, + assistant_warning_at=assistant_warning_at, + confirmation_message="confirm forced restart", + confirmation_at=confirmation_at, + assistant_action_at=assistant_action_at, + ) + + agent_history, _ = _build_gateway_agent_history(history) + + # Fresh confirmation should still be there + confirmation_present = any( + m.get("role") == "user" and "confirm forced restart" in (m.get("content") or "") + for m in agent_history + ) + assert confirmation_present, ( + f"Fresh confirmation (5s old) should NOT be stripped. " + f"Got agent_history: {agent_history}" + ) + + +def test_non_confirmation_text_is_preserved(): + """A regular user message is never treated as a confirmation.""" + current_time = time.time() + user_message_at = current_time - 1000 # 17 min ago + confirmation_at = current_time - 300 # 5 min ago + + history = [ + {"role": "user", "content": "can you help me with the docs?", "timestamp": user_message_at}, + {"role": "assistant", "content": "Sure, what do you need?", "timestamp": confirmation_at}, + ] + + agent_history, _ = _build_gateway_agent_history(history) + + # Both messages should still be there + user_msgs = [m for m in agent_history if m.get("role") == "user"] + assert len(user_msgs) == 1 + assert "help me with the docs" in user_msgs[0].get("content", "") + + +def test_no_dangerous_pattern_at_all_preserves_everything(): + """If the conversation has no dangerous confirmation, nothing is stripped.""" + current_time = time.time() + user_message_at = current_time - 1000 + + history = [ + {"role": "user", "content": "tell me a joke", "timestamp": user_message_at}, + {"role": "assistant", "content": "Why did the chicken cross the road?", "timestamp": user_message_at + 1}, + {"role": "user", "content": "haha", "timestamp": user_message_at + 2}, + ] + + agent_history, _ = _build_gateway_agent_history(history) + + assert len(agent_history) == 3 + + +def test_strip_stale_dangerous_confirmations_directly(): + """Unit test the strip helper in isolation.""" + current_time = time.time() + history = _make_history_with_confirmation( + user_message_at=current_time - 1000, + assistant_warning_at=current_time - 999, + confirmation_message="confirm forced restart", + confirmation_at=current_time - 300, + assistant_action_at=current_time - 299, + ) + + cleaned = _strip_stale_dangerous_confirmations(history, now=current_time) + + # The dangerous confirmation should be gone + assert not any( + "confirm forced restart" in (m.get("content") or "") + for m in cleaned + if m.get("role") == "user" + ) + # The original user question and the assistant responses stay + assert any("can you force a restart" in (m.get("content") or "") for m in cleaned) + assert any("Rebooting the host is dangerous" in (m.get("content") or "") for m in cleaned) + assert any("OK, restarting now" in (m.get("content") or "") for m in cleaned) \ No newline at end of file From e7a6d676c8c6b81ed132e0504f62f2dbf68129c9 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:31:30 +0530 Subject: [PATCH 060/610] fix: redact expired confirmations in place to preserve role alternation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting the matched user message breaks the strict role-alternation invariant on the exact incident tail this fix targets — user(confirm) → assistant('OK, restarting') becomes two consecutive assistant messages, which strict providers reject and which the alternation-repair passes upstream don't cover. Replace the message content with an explicit 'confirmation EXPIRED, re-confirm before any destructive action' sentinel instead: the trigger text is still neutralized, the model gets an affirmative instruction not to act, and the message sequence stays valid. Adds an alternation-preservation regression test. Follow-up to the salvage of #59640 by @knoal. --- agent/replay_cleanup.py | 37 ++++++++++++++----- .../gateway/test_stale_confirmation_expiry.py | 33 ++++++++++++++++- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/agent/replay_cleanup.py b/agent/replay_cleanup.py index 767b13f66050..84815d756fab 100644 --- a/agent/replay_cleanup.py +++ b/agent/replay_cleanup.py @@ -168,6 +168,15 @@ _DANGEROUS_CONFIRMATION_PATTERNS: tuple = ( "確認重啟", ) +# Replacement text for an expired confirmation. Redacting in place (rather +# than deleting the message) preserves strict user/assistant role +# alternation in the replayed history. +_EXPIRED_CONFIRMATION_SENTINEL = ( + "[A high-risk confirmation previously given here has EXPIRED and must " + "not be acted on. Ask the user to re-confirm explicitly before " + "performing any destructive action.]" +) + def is_dangerous_confirmation(content: Any) -> bool: """Return True if a user-message text matches a known dangerous confirmation. @@ -188,7 +197,7 @@ def strip_stale_dangerous_confirmations( now: float, expiry_seconds: float = _DANGEROUS_CONFIRMATION_EXPIRY_SECONDS, ) -> List[Dict[str, Any]]: - """Strip stale dangerous-confirmation text in user messages (#59607). + """Expire stale dangerous-confirmation text in user messages (#59607). When a high-risk side effect (e.g. host restart via ``shutdown.exe``) runs, the user's plain-text confirmation phrase is persisted in the @@ -202,14 +211,19 @@ def strip_stale_dangerous_confirmations( interpret the new turn as a fresh re-confirmation, re-executing the destructive action. This is the failure mode reported in #59607. - This function strips user messages whose content matches a known - dangerous confirmation pattern *and* whose timestamp is older than - ``expiry_seconds`` (default: 60s). Messages without a timestamp are - left untouched (backward compatibility: legacy transcripts and - in-memory test scaffolding have no timestamps). User messages that - contain dangerous confirmation text but are within the expiry window - are also left untouched — they represent a fresh confirmation that - has not yet been acted on. + Expired confirmations are REDACTED IN PLACE, not removed: deleting a + user message from the incident tail (``user(confirm) → + assistant("OK, restarting")``) would leave two consecutive assistant + messages, violating the strict role-alternation invariant providers + enforce. The message survives with its role intact; only the trigger + text is replaced by a sentinel that tells the model the confirmation + has expired. + + Messages without a timestamp are left untouched (backward + compatibility: legacy transcripts and in-memory test scaffolding have + no timestamps). User messages that contain dangerous confirmation + text but are within the expiry window are also left untouched — they + represent a fresh confirmation that has not yet been acted on. Complements 75ed07ace (which strips the *assistant* side of the broken tail) by handling the *user* side: a stale plain-text @@ -229,12 +243,15 @@ def strip_stale_dangerous_confirmations( ts = msg.get("timestamp") if ts is not None and (now - float(ts)) > expiry_seconds: logger.debug( - "Stripping stale dangerous-confirmation text from user " + "Redacting stale dangerous-confirmation text in user " "message (age=%.1fs, expiry=%.1fs): %r", now - float(ts), expiry_seconds, (msg.get("content") or "")[:80], ) + redacted = dict(msg) + redacted["content"] = _EXPIRED_CONFIRMATION_SENTINEL + cleaned.append(redacted) continue cleaned.append(msg) return cleaned diff --git a/tests/gateway/test_stale_confirmation_expiry.py b/tests/gateway/test_stale_confirmation_expiry.py index 431b6ba91172..143d42d74da5 100644 --- a/tests/gateway/test_stale_confirmation_expiry.py +++ b/tests/gateway/test_stale_confirmation_expiry.py @@ -188,4 +188,35 @@ def test_strip_stale_dangerous_confirmations_directly(): # The original user question and the assistant responses stay assert any("can you force a restart" in (m.get("content") or "") for m in cleaned) assert any("Rebooting the host is dangerous" in (m.get("content") or "") for m in cleaned) - assert any("OK, restarting now" in (m.get("content") or "") for m in cleaned) \ No newline at end of file + assert any("OK, restarting now" in (m.get("content") or "") for m in cleaned) + +def test_redaction_preserves_role_alternation(): + """Expiry must redact in place, never delete the user message. + + The incident tail is ``user(confirm) → assistant("OK, restarting")``. + Deleting the user row would leave two consecutive assistant messages, + violating the strict role-alternation invariant providers enforce. + """ + current_time = time.time() + history = _make_history_with_confirmation( + user_message_at=current_time - 1000, + assistant_warning_at=current_time - 999, + confirmation_message="confirm forced restart", + confirmation_at=current_time - 300, + assistant_action_at=current_time - 299, + ) + + cleaned = _strip_stale_dangerous_confirmations(history, now=current_time) + + # Same message count — nothing deleted. + assert len(cleaned) == len(history) + # The user slot survives with an expiry sentinel instead of the phrase. + redacted = cleaned[2] + assert redacted["role"] == "user" + assert "confirm forced restart" not in redacted["content"] + assert "EXPIRED" in redacted["content"] + # No two consecutive same-role messages anywhere. + roles = [m["role"] for m in cleaned] + assert all(a != b for a, b in zip(roles, roles[1:])), roles + # Original history object is not mutated. + assert history[2]["content"] == "confirm forced restart" From 6d3d9d0baf18291e5306691b35f81e5b2e0eecb3 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:33:19 +0530 Subject: [PATCH 061/610] fix: drop timestamp in handle_max_iterations' hand-built api_messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway user replay entries now carry a timestamp (read by the stale-confirmation expiry check). The transports already sanitize it (#47868), but handle_max_iterations hand-builds api_messages and calls chat.completions.create() directly, bypassing the transport — a strict provider would 400 on the foreign key. Mirror the transport's pop here, alongside the existing tool_name/codex_* sanitization. --- agent/chat_completion_helpers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 4b000372b394..d2dc4745c032 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1525,8 +1525,10 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: # hand-builds messages and calls chat.completions.create() directly, # bypassing the transport — so mirror that sanitization here: # tool_name (SQLite FTS bookkeeping), the codex_* reasoning carriers, + # timestamp (preserved on gateway user replay entries for the + # stale-confirmation expiry check — #47868 rejection class), # and every Hermes-internal underscore-prefixed scaffolding key. - for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items"): + for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items", "timestamp"): api_msg.pop(schema_foreign, None) for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]: api_msg.pop(internal_key, None) From 07d93413e5e908859ddf6c4ea4b60c21131c10d3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:10:43 -0700 Subject: [PATCH 062/610] fix: default memory null target to memory store (#46356) Port from nearai/ironclaw#4547: treat a JSON null memory target as omitted so strict providers that fill optional fields with null use the documented default target instead of failing validation. --- tests/tools/test_memory_tool.py | 20 ++++++++++++++++++++ tools/memory_tool.py | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/tests/tools/test_memory_tool.py b/tests/tools/test_memory_tool.py index a7c822cb18db..c2903f375c6d 100644 --- a/tests/tools/test_memory_tool.py +++ b/tests/tools/test_memory_tool.py @@ -535,6 +535,26 @@ class TestMemoryToolDispatcher: result = json.loads(memory_tool(action="add", target="invalid", content="x", store=store)) assert result["success"] is False + def test_null_target_defaults_to_memory_store(self, store): + result = json.loads( + memory_tool( + action="add", + target=None, + content="Project uses pytest with xdist.", + store=store, + ) + ) + assert result["success"] is True + assert store.memory_entries == ["Project uses pytest with xdist."] + assert store.user_entries == [] + + def test_invalid_non_string_target_still_rejected(self, store): + result = json.loads( + memory_tool(action="add", target=42, content="via tool", store=store) + ) + assert result["success"] is False + assert "Invalid target" in result["error"] + def test_unknown_action(self, store): result = json.loads(memory_tool(action="unknown", store=store)) assert result["success"] is False diff --git a/tools/memory_tool.py b/tools/memory_tool.py index 02315bd0726a..08eeaa470ea4 100644 --- a/tools/memory_tool.py +++ b/tools/memory_tool.py @@ -977,6 +977,12 @@ def memory_tool( if store is None: return tool_error("Memory is not available. It may be disabled in config or this environment.", success=False) + # Some strict providers fill optional schema fields with JSON null rather + # than omitting them. Treat ``target: null`` as omitted so memory writes + # still use the documented default store instead of failing validation. + if target is None: + target = "memory" + if target not in {"memory", "user"}: return tool_error(f"Invalid target '{target}'. Use 'memory' or 'user'.", success=False) From 62972060caaa9f7f3fc5a58688757fdd59b9a117 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Tue, 7 Jul 2026 02:59:03 +0700 Subject: [PATCH 063/610] fix(cron): normalize absolute skill paths before skill_view (#59824) Cron jobs may store absolute paths to skills under HERMES_HOME/skills or external_dirs, but skill_view rejects absolute names for security. Extract the slash-command normalization into agent.skill_utils and reuse it when cron loads job skills. --- agent/skill_commands.py | 32 ++------------------------------ agent/skill_utils.py | 40 ++++++++++++++++++++++++++++++++++++++++ cron/scheduler.py | 3 ++- 3 files changed, 44 insertions(+), 31 deletions(-) diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 5823a443dd9e..f4f470c4c014 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -143,37 +143,9 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu try: from tools.skills_tool import SKILLS_DIR, skill_view - from agent.skill_utils import get_external_skills_dirs + from agent.skill_utils import normalize_skill_lookup_name - identifier_path = Path(raw_identifier).expanduser() - if identifier_path.is_absolute(): - normalized = None - trusted_roots = [SKILLS_DIR] - try: - trusted_roots.extend(get_external_skills_dirs()) - except Exception: - pass - - # Prefer the lexical path under a trusted skill root before - # resolving symlinks. Slash-command discovery can legitimately - # find a skill via ~/.hermes/skills/ where is a - # symlink to a checked-out skill elsewhere. Resolving first turns - # that trusted visible path into an arbitrary absolute path that - # skill_view() refuses to load. - for root in trusted_roots: - try: - normalized = str(identifier_path.relative_to(root)) - break - except ValueError: - continue - - if normalized is None: - try: - normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve())) - except Exception: - normalized = raw_identifier - else: - normalized = raw_identifier.lstrip("/") + normalized = normalize_skill_lookup_name(raw_identifier) loaded_skill = json.loads( skill_view(normalized, task_id=task_id, preprocess=False) diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 187c47d70303..4436f7749bfa 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -507,6 +507,46 @@ def get_all_skills_dirs() -> List[Path]: return dirs +def normalize_skill_lookup_name(identifier: str) -> str: + """Normalize a skill identifier to a ``skill_view()``-safe relative path. + + Slash commands and cron jobs may store absolute paths to skills that live + under ``~/.hermes/skills/`` (including via symlinks) or configured + ``skills.external_dirs``. ``skill_view()`` rejects absolute names for + security, so callers must translate trusted absolute paths to their + relative form first. + """ + raw_identifier = (identifier or "").strip() + if not raw_identifier: + return raw_identifier + + identifier_path = Path(raw_identifier).expanduser() + if not identifier_path.is_absolute(): + return raw_identifier.lstrip("/") + + trusted_roots = [get_skills_dir()] + try: + trusted_roots.extend(get_external_skills_dirs()) + except Exception: + pass + + # Prefer the lexical path under a trusted skill root before resolving + # symlinks. Slash-command discovery can legitimately find a skill via + # ~/.hermes/skills/ where is a symlink to a checked-out + # skill elsewhere. Resolving first turns that trusted visible path into + # an arbitrary absolute path that skill_view() refuses to load. + for root in trusted_roots: + try: + return str(identifier_path.relative_to(root)) + except ValueError: + continue + + try: + return str(identifier_path.resolve().relative_to(get_skills_dir().resolve())) + except Exception: + return raw_identifier + + def _resolve_for_skill_ownership(path) -> Path: path_obj = path if isinstance(path, Path) else Path(str(path)) try: diff --git a/cron/scheduler.py b/cron/scheduler.py index b4e6001d19a9..21baab05482f 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -2195,6 +2195,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str: from tools.skills_tool import skill_view from tools.skill_usage import bump_use from agent.skill_bundles import build_bundle_invocation_message, resolve_bundle_command_key + from agent.skill_utils import normalize_skill_lookup_name parts = [] skipped: list[str] = [] @@ -2225,7 +2226,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str: continue try: - loaded = json.loads(skill_view(skill_name)) + loaded = json.loads(skill_view(normalize_skill_lookup_name(skill_name))) except (json.JSONDecodeError, TypeError): logger.warning("Cron job '%s': skill '%s' returned invalid JSON, skipping", job.get("name", job.get("id")), skill_name) skipped.append(skill_name) From e7082ea99fc502169287b86e043b8bf4260c19d8 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Tue, 7 Jul 2026 02:59:03 +0700 Subject: [PATCH 064/610] test(cron): cover absolute skill path normalization (#59824) Add unit tests for normalize_skill_lookup_name and a cron scheduler regression that absolute paths under the skills dir reach skill_view as relative lookups. --- tests/agent/test_skill_utils.py | 39 +++++++++++++++++++++++++++++++++ tests/cron/test_scheduler.py | 25 +++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/tests/agent/test_skill_utils.py b/tests/agent/test_skill_utils.py index ab3cfe280aee..7fcc21eebccc 100644 --- a/tests/agent/test_skill_utils.py +++ b/tests/agent/test_skill_utils.py @@ -333,3 +333,42 @@ class TestSkillMatchesPlatformTermux: "agent.skill_utils.is_termux", return_value=False ): assert skill_matches_platform(fm) is True + + +class TestNormalizeSkillLookupName: + def test_relative_path_unchanged(self, tmp_path, monkeypatch): + from agent.skill_utils import normalize_skill_lookup_name + + monkeypatch.setattr("agent.skill_utils.get_skills_dir", lambda: tmp_path / "skills") + assert normalize_skill_lookup_name("foo/bar") == "foo/bar" + + def test_absolute_under_skills_dir_becomes_relative(self, tmp_path, monkeypatch): + from agent.skill_utils import normalize_skill_lookup_name + + skills_dir = tmp_path / "skills" + skill_dir = skills_dir / "category" / "my-skill" + skill_dir.mkdir(parents=True) + monkeypatch.setattr("agent.skill_utils.get_skills_dir", lambda: skills_dir) + assert normalize_skill_lookup_name(str(skill_dir)) == "category/my-skill" + + def test_absolute_via_symlink_uses_lexical_relative_path(self, tmp_path, monkeypatch): + from agent.skill_utils import normalize_skill_lookup_name + + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + external = tmp_path / "external" / "my-skill" + external.mkdir(parents=True) + link = skills_dir / "my-skill" + try: + link.symlink_to(external) + except OSError: + pytest.skip("Symlinks not supported") + monkeypatch.setattr("agent.skill_utils.get_skills_dir", lambda: skills_dir) + assert normalize_skill_lookup_name(str(link)) == "my-skill" + + def test_untrusted_absolute_returned_unchanged(self, tmp_path, monkeypatch): + from agent.skill_utils import normalize_skill_lookup_name + + monkeypatch.setattr("agent.skill_utils.get_skills_dir", lambda: tmp_path / "skills") + outside = str(tmp_path / "outside" / "skill") + assert normalize_skill_lookup_name(outside) == outside diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 775840ecec83..663efc87368b 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -2885,6 +2885,31 @@ class TestBuildJobPromptMissingSkill: assert "go" in result +class TestBuildJobPromptAbsoluteSkillPath: + """Cron jobs may store absolute skill paths; normalize before skill_view.""" + + def test_absolute_skill_path_normalized_before_skill_view(self, tmp_path): + skills_dir = tmp_path / "skills" + skill_dir = skills_dir / "alpha-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("# Alpha\nDo alpha.") + absolute_path = str(skill_dir) + seen_names: list[str] = [] + + def _skill_view(name: str) -> str: + seen_names.append(name) + if name == "alpha-skill": + return json.dumps({"success": True, "content": "# Alpha\nDo alpha."}) + return json.dumps({"success": False, "error": f"Skill '{name}' not found."}) + + with patch("agent.skill_utils.get_skills_dir", return_value=skills_dir), \ + patch("tools.skills_tool.skill_view", side_effect=_skill_view): + result = _build_job_prompt({"skills": [absolute_path], "prompt": "go"}) + + assert seen_names == ["alpha-skill"] + assert "Do alpha." in result + + class TestBuildJobPromptBumpUse: """Verify that cron jobs bump skill usage counters so the curator sees them as active.""" From 713e50e7d28507d78177b2b6d59f9bab01c6865e Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:29:19 +0530 Subject: [PATCH 065/610] fix: normalize against tools.skills_tool.SKILLS_DIR, the root skill_view enforces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extracted normalize_skill_lookup_name() resolved trusted roots via agent.skill_utils.get_skills_dir(), but skill_view() enforces tools.skills_tool.SKILLS_DIR — a separate module attribute that callers and 60+ existing tests patch directly. With the helper reading a different symbol than the enforcer, any SKILLS_DIR patch (or future divergence between the two resolvers) makes normalization disagree with enforcement and absolute-path loads regress silently. Read SKILLS_DIR at call time (deferred import, cycle-safe) with get_skills_dir() as the fallback, and align the new tests to patch the enforced symbol. Follow-up to the salvage of #59829 by @HexLab98. --- agent/skill_utils.py | 16 ++++++++++++++-- tests/agent/test_skill_utils.py | 7 +++++-- tests/cron/test_scheduler.py | 2 +- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 4436f7749bfa..6b83852eaaad 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -524,7 +524,19 @@ def normalize_skill_lookup_name(identifier: str) -> str: if not identifier_path.is_absolute(): return raw_identifier.lstrip("/") - trusted_roots = [get_skills_dir()] + # Look the primary skills root up on tools.skills_tool at CALL time + # (not via get_skills_dir()): callers and tests patch + # ``tools.skills_tool.SKILLS_DIR`` and skill_view() itself resolves + # against that module attribute, so normalization must agree with the + # exact root skill_view() will enforce. Import deferred to avoid a + # module cycle (tools.skills_tool imports agent.skill_utils). + try: + from tools import skills_tool as _skills_tool + primary_root = Path(_skills_tool.SKILLS_DIR) + except Exception: + primary_root = get_skills_dir() + + trusted_roots = [primary_root] try: trusted_roots.extend(get_external_skills_dirs()) except Exception: @@ -542,7 +554,7 @@ def normalize_skill_lookup_name(identifier: str) -> str: continue try: - return str(identifier_path.resolve().relative_to(get_skills_dir().resolve())) + return str(identifier_path.resolve().relative_to(primary_root.resolve())) except Exception: return raw_identifier diff --git a/tests/agent/test_skill_utils.py b/tests/agent/test_skill_utils.py index 7fcc21eebccc..8439253d523a 100644 --- a/tests/agent/test_skill_utils.py +++ b/tests/agent/test_skill_utils.py @@ -348,7 +348,9 @@ class TestNormalizeSkillLookupName: skills_dir = tmp_path / "skills" skill_dir = skills_dir / "category" / "my-skill" skill_dir.mkdir(parents=True) - monkeypatch.setattr("agent.skill_utils.get_skills_dir", lambda: skills_dir) + # Patch the root skill_view() itself enforces — normalization reads + # tools.skills_tool.SKILLS_DIR at call time so the two stay in sync. + monkeypatch.setattr("tools.skills_tool.SKILLS_DIR", skills_dir) assert normalize_skill_lookup_name(str(skill_dir)) == "category/my-skill" def test_absolute_via_symlink_uses_lexical_relative_path(self, tmp_path, monkeypatch): @@ -363,12 +365,13 @@ class TestNormalizeSkillLookupName: link.symlink_to(external) except OSError: pytest.skip("Symlinks not supported") - monkeypatch.setattr("agent.skill_utils.get_skills_dir", lambda: skills_dir) + monkeypatch.setattr("tools.skills_tool.SKILLS_DIR", skills_dir) assert normalize_skill_lookup_name(str(link)) == "my-skill" def test_untrusted_absolute_returned_unchanged(self, tmp_path, monkeypatch): from agent.skill_utils import normalize_skill_lookup_name + monkeypatch.setattr("tools.skills_tool.SKILLS_DIR", tmp_path / "skills") monkeypatch.setattr("agent.skill_utils.get_skills_dir", lambda: tmp_path / "skills") outside = str(tmp_path / "outside" / "skill") assert normalize_skill_lookup_name(outside) == outside diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 663efc87368b..8cb81cb6177a 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -2902,7 +2902,7 @@ class TestBuildJobPromptAbsoluteSkillPath: return json.dumps({"success": True, "content": "# Alpha\nDo alpha."}) return json.dumps({"success": False, "error": f"Skill '{name}' not found."}) - with patch("agent.skill_utils.get_skills_dir", return_value=skills_dir), \ + with patch("tools.skills_tool.SKILLS_DIR", skills_dir), \ patch("tools.skills_tool.skill_view", side_effect=_skill_view): result = _build_job_prompt({"skills": [absolute_path], "prompt": "go"}) From 2c5762f5755ed6d63ae43e71178b540055cc7936 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:34:12 +0530 Subject: [PATCH 066/610] chore: debug log for untrusted absolute skill paths; drop misleading test patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings: (a) an absolute path outside trusted roots passes through unchanged and gets rejected downstream by skill_view — add a debug log at the pass-through so the cron 'skill not found' symptom is diagnosable next time; (b) test_relative_path_unchanged patched get_skills_dir although the relative branch early-returns before any root lookup — drop the misleading patch. --- agent/skill_utils.py | 5 +++++ tests/agent/test_skill_utils.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 6b83852eaaad..23c8d99c997d 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -556,6 +556,11 @@ def normalize_skill_lookup_name(identifier: str) -> str: try: return str(identifier_path.resolve().relative_to(primary_root.resolve())) except Exception: + logger.debug( + "Skill identifier %r is an absolute path outside trusted skills " + "roots — passing through unchanged (skill_view will reject it)", + raw_identifier, + ) return raw_identifier diff --git a/tests/agent/test_skill_utils.py b/tests/agent/test_skill_utils.py index 8439253d523a..347d1b667c7a 100644 --- a/tests/agent/test_skill_utils.py +++ b/tests/agent/test_skill_utils.py @@ -339,7 +339,7 @@ class TestNormalizeSkillLookupName: def test_relative_path_unchanged(self, tmp_path, monkeypatch): from agent.skill_utils import normalize_skill_lookup_name - monkeypatch.setattr("agent.skill_utils.get_skills_dir", lambda: tmp_path / "skills") + # Relative identifiers early-return before any root lookup. assert normalize_skill_lookup_name("foo/bar") == "foo/bar" def test_absolute_under_skills_dir_becomes_relative(self, tmp_path, monkeypatch): From 3c8130a82669042112554d2e6ee79a3f29c1e286 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:42:46 +0530 Subject: [PATCH 067/610] fix: re-apply confirmation expiry on the cached-agent live-history path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: when the FTS write-corruption guard (#50502) prefers the cached agent's live _session_messages over the reloaded transcript, that history bypasses the replay-cleanup pass in _build_gateway_agent_history — a stale dangerous confirmation could slip through unredacted on the same-process salvage path. Re-apply the (idempotent) expiry stripper to the selected live history. --- gateway/run.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/gateway/run.py b/gateway/run.py index d47b11ebb770..ef68995cef11 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -18135,7 +18135,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "conversation context (possible FTS write corruption)", session_key, len(agent_history), len(_selected), ) - agent_history = _selected + # The live in-memory history bypassed the replay-cleanup + # pass inside _build_gateway_agent_history — re-apply the + # stale-confirmation expiry (#59607) so a dangerous + # confirmation can't slip through this path either. + # Idempotent; messages without timestamps are untouched. + agent_history = _strip_stale_dangerous_confirmations( + _selected, now=time.time() + ) # Collect MEDIA paths already in history so we can exclude them # from the current turn's extraction. This is compression-safe: From 8a7d0790dffbcaf6bfb94dfc0f9ed794a2a5525f Mon Sep 17 00:00:00 2001 From: williamumu Date: Sun, 24 May 2026 00:56:27 +0800 Subject: [PATCH 068/610] fix: merge split gateway pairing stores --- gateway/pairing.py | 52 ++++++++++++++++++++++++++++++++++- tests/gateway/test_pairing.py | 42 ++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/gateway/pairing.py b/gateway/pairing.py index 4e3712c117b1..bf6212f97f3f 100644 --- a/gateway/pairing.py +++ b/gateway/pairing.py @@ -33,7 +33,7 @@ from gateway.whatsapp_identity import ( expand_whatsapp_aliases, normalize_whatsapp_identifier, ) -from hermes_constants import get_hermes_dir +from hermes_constants import get_hermes_dir, get_hermes_home from utils import atomic_replace logger = logging.getLogger(__name__) @@ -161,6 +161,51 @@ def _sync_allowlist_remove(platform: str, user_id: str) -> None: pass +def _load_json_file(path: Path) -> dict: + if path.exists(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except (json.JSONDecodeError, OSError): + return {} + return {} + + +def _merge_pairing_dir(active_dir: Path, alternate_dir: Path) -> None: + """Merge split legacy/new pairing data into the active PairingStore dir. + + Older installs use ``{HERMES_HOME}/pairing`` while newer code/docs may + write ``{HERMES_HOME}/platforms/pairing``. If both directories exist, the + gateway must not silently ignore approved users sitting in the inactive + location; otherwise already-paired Feishu users get asked for a fresh code. + """ + if not alternate_dir.exists() or active_dir.resolve() == alternate_dir.resolve(): + return + active_dir.mkdir(parents=True, exist_ok=True) + for src in alternate_dir.glob("*.json"): + if not src.is_file(): + continue + dest = active_dir / src.name + merged = _load_json_file(src) + if not merged: + continue + current = _load_json_file(dest) + before = dict(current) + # Active data wins on key conflict; otherwise union the inactive data. + merged.update(current) + if merged != before: + _secure_write(dest, json.dumps(merged, indent=2, ensure_ascii=False)) + + +def _migrate_split_pairing_dirs() -> None: + home = get_hermes_home() + old_dir = home / "pairing" + new_dir = home / "platforms" / "pairing" + active = PAIRING_DIR + alternate = new_dir if active.resolve() == old_dir.resolve() else old_dir + _merge_pairing_dir(active, alternate) + + def _secure_write(path: Path, data: str) -> None: """Write data to file with restrictive permissions (owner read/write only). @@ -212,6 +257,11 @@ class PairingStore: else: self._dir = PAIRING_DIR self._dir.mkdir(parents=True, exist_ok=True) + if not profile: + # Heal installs whose global pairing data ended up split across + # the legacy and new directories (per-profile stores never had + # the legacy/new split). + _migrate_split_pairing_dirs() # Protects all read-modify-write cycles. The gateway runs multiple # platform adapters concurrently in threads sharing one PairingStore. self._lock = threading.RLock() diff --git a/tests/gateway/test_pairing.py b/tests/gateway/test_pairing.py index aa4a9b1e9f48..ef1037692e6b 100644 --- a/tests/gateway/test_pairing.py +++ b/tests/gateway/test_pairing.py @@ -27,6 +27,48 @@ def _make_store(tmp_path): return PairingStore() +class TestSplitPairingDirMigration: + def test_merges_new_approved_into_active_legacy_dir(self, tmp_path): + home = tmp_path / "home" + legacy = home / "pairing" + new = home / "platforms" / "pairing" + legacy.mkdir(parents=True) + new.mkdir(parents=True) + (new / "feishu-approved.json").write_text(json.dumps({ + "ou_user": {"user_name": "Alice", "approved_at": 123.0} + })) + + with patch("gateway.pairing.PAIRING_DIR", legacy), patch("gateway.pairing.get_hermes_home", return_value=home): + store = PairingStore() + assert store.is_approved("feishu", "ou_user") is True + + migrated = json.loads((legacy / "feishu-approved.json").read_text()) + assert "ou_user" in migrated + + def test_active_entries_win_when_merging_split_dirs(self, tmp_path): + home = tmp_path / "home" + legacy = home / "pairing" + new = home / "platforms" / "pairing" + legacy.mkdir(parents=True) + new.mkdir(parents=True) + (legacy / "feishu-approved.json").write_text(json.dumps({ + "ou_user": {"user_name": "Active", "approved_at": 2.0} + })) + (new / "feishu-approved.json").write_text(json.dumps({ + "ou_user": {"user_name": "Inactive", "approved_at": 1.0}, + "ou_other": {"user_name": "Other", "approved_at": 1.0}, + })) + + with patch("gateway.pairing.PAIRING_DIR", legacy), patch("gateway.pairing.get_hermes_home", return_value=home): + store = PairingStore() + assert store.is_approved("feishu", "ou_user") is True + assert store.is_approved("feishu", "ou_other") is True + + migrated = json.loads((legacy / "feishu-approved.json").read_text()) + assert migrated["ou_user"]["user_name"] == "Active" + assert migrated["ou_other"]["user_name"] == "Other" + + # --------------------------------------------------------------------------- # _secure_write # --------------------------------------------------------------------------- From 179ca25a38ae30471afc7dd52a71d65179fad19a Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Jul 2026 05:53:41 -0700 Subject: [PATCH 069/610] chore: add williamumu to AUTHOR_MAP for PR #31041 salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index ba098a9f10fc..32f7c577a27c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -62,6 +62,7 @@ AUTHOR_MAP = { "39274208+falkoro@users.noreply.github.com": "falkoro", # PRs #58519/#58520 salvage (config: env-ref-aware load_config cache invalidation; auxiliary: honor auxiliary..base_url/api_key with explicit provider arg) "3723267+kevinrajaram@users.noreply.github.com": "kevinrajaram", # PR #3850 salvage (gateway: add POSIX system dirs to PATH so launchctl/systemctl resolve under UV's minimal-PATH bundled Python; #3849) "lord-dubious@users.noreply.github.com": "lord-dubious", # PR #58453 salvage (preserve static custom provider models declared as dict rows) + "williamumu@users.noreply.github.com": "williamumu", # PR #31041 salvage (pairing: merge split legacy/new pairing store dirs at PairingStore init so approved users aren't re-prompted to pair) "jonathan@mintrx.com": "JAlmanzarMint", # PR #52688 salvage (vision: rasterize SVG / re-encode unsupported raster formats to PNG before embedding), folded into #57890 "al3060388206@gmail.com": "ooiuuii", # PR #58466/#58377 salvage (redact: fireworks fw-/fpk_ prefixes; telegram: redact bot tokens out of transport error strings). Also PR #58433 salvage (codex: accept recorded final_text when app-server omits turn/completed) and PR #58472 salvage (gateway: cap proxy SSE line buffer at 16MiB). "Jigoooo@users.noreply.github.com": "Jigoooo", # PR #58474 salvage (auxiliary: fall back to token resolver when anthropic pool has no usable entry) From 2e30a5e62891ea07afb631ebca693dbb01c4468a Mon Sep 17 00:00:00 2001 From: isheng Date: Tue, 7 Jul 2026 13:30:24 +0800 Subject: [PATCH 070/610] fix: prevent /stop signal loss and empty provider credential corruption Two deep bugs found through systematic analysis of the streaming API call and fallback credential subsystems: 1. Interrupt signal loss (chat_completion_helpers.py): When the worker thread exits before the main thread's poll loop checks the interrupt flag (e.g. _call_anthropic() detects the flag and returns None), the while loop exits normally and the InterruptedError is never raised. /stop is silently swallowed. Fix: re-check _interrupt_requested after the while loop exits. 2. Empty provider bypasses credential guard (agent_runtime_helpers.py): recover_with_credential_pool() guards against cross-provider pool swaps with 'if current_provider and pool_provider and current != pool_provider'. When agent.provider is '' (valid unset state from agent_init.py:326), current_provider is falsy, the guard is skipped, and the pool swaps credentials onto an agent with empty provider. This is the root cause of the 'provider= model=' empty-string error. Fix: only skip the guard when pool_provider is empty (unscoped pool), not when agent provider is empty. --- agent/agent_runtime_helpers.py | 9 ++++++++- agent/chat_completion_helpers.py | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 691d796273f7..b3a9cf5321c6 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -729,7 +729,14 @@ def recover_with_credential_pool( # that seeded the pool. current_provider = (getattr(agent, "provider", "") or "").strip().lower() pool_provider = (getattr(pool, "provider", "") or "").strip().lower() - if current_provider and pool_provider and current_provider != pool_provider: + # Guard: skip credential pool recovery when the pool is scoped to a + # different provider than the agent. Only guard when the pool has a + # known provider — an empty pool provider means "unscoped" (applies to + # any provider). An empty agent provider is treated as a mismatch + # because swapping the pool's credentials would set base_url/api_key + # without fixing the empty provider field, leaving the agent in a + # corrupted state (provider="" model=""). + if pool_provider and current_provider != pool_provider: # Custom endpoints use two naming conventions for the SAME provider: # the agent carries the generic ``custom`` label while the pool is # keyed ``custom:`` (see CUSTOM_POOL_PREFIX). A literal string diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index d2dc4745c032..0c4968b5fc63 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2902,6 +2902,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= except Exception: pass raise InterruptedError("Agent interrupted during streaming API call") + # Worker thread exited before the main thread's poll loop could check + # the interrupt flag. If the worker returned early due to an interrupt + # (e.g. _call_anthropic() detected _interrupt_requested and returned + # None), the InterruptedError above was never raised. Re-check the + # flag here so /stop is not silently swallowed. (#59999 area) + if agent._interrupt_requested: + raise InterruptedError("Agent interrupted during streaming API call (post-worker)") if result["error"] is not None: if deltas_were_sent["yes"]: # Streaming failed AFTER some tokens were already delivered to From c2c73605e04f4ea4c578ebb5dc2880803853c368 Mon Sep 17 00:00:00 2001 From: isheng Date: Tue, 7 Jul 2026 13:38:56 +0800 Subject: [PATCH 071/610] test: set pool.provider= on mocks to avoid MagicMock truthy guard trigger The provider-mismatch guard now checks pool_provider and current_provider != pool_provider. MagicMock.provider returns a truthy child mock by default, which would trigger the guard and skip the pool recovery tests. Set pool.provider='' explicitly. --- tests/agent/test_credential_pool_routing.py | 3 +++ tests/run_agent/test_credential_pool_interrupt.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/tests/agent/test_credential_pool_routing.py b/tests/agent/test_credential_pool_routing.py index 8477fdb646a8..9c7c04730515 100644 --- a/tests/agent/test_credential_pool_routing.py +++ b/tests/agent/test_credential_pool_routing.py @@ -155,6 +155,9 @@ class TestPoolRotationCycle: pool = MagicMock() pool.has_credentials.return_value = True + # Must be set explicitly — MagicMock.provider returns a truthy + # child mock, which would trigger the provider-mismatch guard. + pool.provider = "" # mark_exhausted_and_rotate returns next entry until exhausted self._rotation_index = 0 diff --git a/tests/run_agent/test_credential_pool_interrupt.py b/tests/run_agent/test_credential_pool_interrupt.py index 8dab8da949af..19e68e1c61c2 100644 --- a/tests/run_agent/test_credential_pool_interrupt.py +++ b/tests/run_agent/test_credential_pool_interrupt.py @@ -27,6 +27,9 @@ def _make_pool(entries): pool = MagicMock() pool.entries = entries pool.current.return_value = entries[0] + # Must be set explicitly — MagicMock.provider returns a truthy + # child mock, which would trigger the provider-mismatch guard. + pool.provider = "" return pool From 144457d801ab301ec8944e30c9c9109a06f6f5d6 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:48:24 +0530 Subject: [PATCH 072/610] fix(interrupt): extend post-worker /stop guard to Bedrock streaming path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The salvaged fix added a post-worker _interrupt_requested re-check to the main OpenAI/Anthropic streaming poll loop. The Bedrock Converse poll loop (interruptible_streaming_api_call, api_mode='bedrock_converse') has the same bug class: its worker calls stream_converse_with_callbacks(on_interrupt_check= ...), which breaks out of the event loop on interrupt and returns a PARTIAL response WITHOUT raising (bedrock_adapter.py). The worker sets result[ 'response'] and exits with _interrupt_requested still True, so the in-loop raise never fires and the poll loop returns the partial — silently swallowing /stop on Bedrock exactly as it was on the paths the salvaged commit fixed. Add the identical post-worker re-check before the Bedrock loop's return. The non-streaming loop (interruptible_api_call) is structurally immune: its worker's only early return fires off _request_cancelled, which is set by the main loop immediately before it raises in-loop, so no swallow window exists. Guard test flips _interrupt_requested True mid-stream (after the pre-flight check) and asserts InterruptedError is raised; verified RED without the fix (DID NOT RAISE) and GREEN with it. --- agent/chat_completion_helpers.py | 10 +++ .../test_bedrock_interrupt_post_worker.py | 88 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 tests/agent/test_bedrock_interrupt_post_worker.py diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 0c4968b5fc63..72e6920cdbab 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1881,6 +1881,16 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= t.join(timeout=0.3) if agent._interrupt_requested: raise InterruptedError("Agent interrupted during Bedrock API call") + # Worker exited before the poll loop observed the interrupt flag. The + # Bedrock stream callback breaks out and returns a PARTIAL response + # without raising on interrupt (see bedrock_adapter.py + # stream_converse_with_callbacks / on_interrupt_check), so result[ + # "response"] is populated with error=None and the in-loop raise above + # never fires. Re-check here so /stop is not silently swallowed on the + # Bedrock path — mirrors the post-worker guard on the main streaming + # loop. (#59999 area) + if agent._interrupt_requested: + raise InterruptedError("Agent interrupted during Bedrock API call (post-worker)") if result["error"] is not None: raise result["error"] return result["response"] diff --git a/tests/agent/test_bedrock_interrupt_post_worker.py b/tests/agent/test_bedrock_interrupt_post_worker.py new file mode 100644 index 000000000000..5a5829864b5d --- /dev/null +++ b/tests/agent/test_bedrock_interrupt_post_worker.py @@ -0,0 +1,88 @@ +"""Regression: /stop must not be swallowed on the Bedrock streaming path. + +Companion to the OpenAI/Anthropic streaming post-worker guard. The Bedrock +Converse stream callback (bedrock_adapter.stream_converse_with_callbacks) breaks +out of its event loop on interrupt and returns a PARTIAL response WITHOUT +raising. The worker thread then sets result["response"] and exits cleanly with +agent._interrupt_requested still True. Without a post-worker re-check in the +poll loop, interruptible_streaming_api_call would return that partial response +and silently swallow the /stop signal. +""" +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from agent import chat_completion_helpers as cch + + +class _FakeAgent: + api_mode = "bedrock_converse" + _interrupt_requested = False # not interrupted at entry (passes pre-flight) + _disable_streaming = False + reasoning_callback = None + stream_delta_callback = None + + def _has_stream_consumers(self): + return False + + def _fire_stream_delta(self, text): + pass + + def _fire_tool_gen_started(self, name): + pass + + def _fire_reasoning_delta(self, text): + pass + + def _safe_print(self, *a, **k): + pass + + +def test_bedrock_stream_interrupt_not_swallowed_post_worker(): + """A /stop arriving MID-stream: the pre-flight check (top of function) has + already passed, the worker's stream callback breaks and returns a partial + response WITHOUT raising, leaving _interrupt_requested True. The post-worker + re-check must raise InterruptedError instead of returning the partial.""" + agent = _FakeAgent() + + partial = SimpleNamespace(choices=[], usage=None, stop_reason="interrupted") + + # Simulate the real adapter: on interrupt it breaks out and returns a + # partial response WITHOUT raising. Flip the interrupt flag here to model + # /stop arriving mid-stream (after the pre-flight check, during the worker). + def _fake_stream(*args, **kwargs): + agent._interrupt_requested = True + return partial + + fake_client = SimpleNamespace(converse_stream=lambda **kw: {"stream": []}) + + with patch("agent.bedrock_adapter._get_bedrock_runtime_client", return_value=fake_client), \ + patch("agent.bedrock_adapter.stream_converse_with_callbacks", side_effect=_fake_stream), \ + patch("agent.bedrock_adapter.normalize_converse_response", side_effect=lambda r: r), \ + patch("agent.bedrock_adapter.is_stale_connection_error", return_value=False), \ + patch("agent.bedrock_adapter.is_streaming_access_denied_error", return_value=False), \ + patch("agent.bedrock_adapter.invalidate_runtime_client", lambda *a, **k: None): + api_kwargs = {"__bedrock_region__": "us-east-1", "__bedrock_converse__": True} + with pytest.raises(InterruptedError): + cch.interruptible_streaming_api_call(agent, api_kwargs) + + +def test_bedrock_stream_returns_normally_when_not_interrupted(): + """Sanity: with no interrupt, the same path returns the response (guard + must not fire spuriously).""" + agent = _FakeAgent() + agent._interrupt_requested = False + + resp = SimpleNamespace(choices=[], usage=None, stop_reason="end_turn") + fake_client = SimpleNamespace(converse_stream=lambda **kw: {"stream": []}) + + with patch("agent.bedrock_adapter._get_bedrock_runtime_client", return_value=fake_client), \ + patch("agent.bedrock_adapter.stream_converse_with_callbacks", return_value=resp), \ + patch("agent.bedrock_adapter.normalize_converse_response", side_effect=lambda r: r), \ + patch("agent.bedrock_adapter.is_stale_connection_error", return_value=False), \ + patch("agent.bedrock_adapter.is_streaming_access_denied_error", return_value=False), \ + patch("agent.bedrock_adapter.invalidate_runtime_client", lambda *a, **k: None): + api_kwargs = {"__bedrock_region__": "us-east-1", "__bedrock_converse__": True} + out = cch.interruptible_streaming_api_call(agent, api_kwargs) + assert out is resp From fc02b1c2766e4b52c30d5fb2353aa29ea81cfa7c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 13 Jun 2026 06:52:15 -0700 Subject: [PATCH 073/610] refactor(cli): simplify safe-mode startup wiring Since safe mode already landed on main via #45488, reduce this branch to cleanup: centralize env setup, remove duplicated comments, and tighten tests. --- hermes_cli/main.py | 24 ++-- hermes_cli/plugins.py | 4 - tests/hermes_cli/test_safe_mode.py | 178 +++++++++++++++-------------- tools/mcp_tool.py | 3 +- 4 files changed, 103 insertions(+), 106 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 6697435786bf..e08246f16828 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2217,6 +2217,8 @@ def cmd_chat(args): """Run interactive chat CLI.""" use_tui = _resolve_use_tui(args) + _apply_safe_mode(args) + # Resolve --continue into --resume with the latest session or by name continue_val = getattr(args, "continue_last", None) if continue_val and not getattr(args, "resume", None): @@ -2327,18 +2329,6 @@ def cmd_chat(args): if getattr(args, "yolo", False): os.environ["HERMES_YOLO_MODE"] = "1" - # --safe-mode: troubleshooting mode that disables ALL customizations. - # Inspired by Claude Code v2.1.169's --safe-mode (June 2026): run with a - # pristine environment to isolate whether a problem comes from the user's - # setup (config, rules files, plugins, MCP servers) or from Hermes itself. - # Implemented as a superset of --ignore-user-config + --ignore-rules plus - # plugin/MCP discovery suppression (HERMES_SAFE_MODE is checked by - # hermes_cli/plugins.py and tools/mcp_tool.py). - if getattr(args, "safe_mode", False): - os.environ["HERMES_SAFE_MODE"] = "1" - os.environ["HERMES_IGNORE_USER_CONFIG"] = "1" - os.environ["HERMES_IGNORE_RULES"] = "1" - # --ignore-user-config: make load_cli_config() / load_config() skip the # user's ~/.hermes/config.yaml and return built-in defaults. Set BEFORE # importing cli (which runs `CLI_CONFIG = load_cli_config()` at module @@ -12351,6 +12341,8 @@ def _should_background_mcp_startup(args) -> bool: def _prepare_agent_startup(args) -> None: """Discover plugins/MCP/hooks for commands that can run an agent turn.""" + _apply_safe_mode(args) + _sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None)) if not ( args.command in _AGENT_COMMANDS @@ -12416,6 +12408,14 @@ def _prepare_agent_startup(args) -> None: ) +def _apply_safe_mode(args) -> None: + if not getattr(args, "safe_mode", False): + return + os.environ["HERMES_SAFE_MODE"] = "1" + os.environ["HERMES_IGNORE_USER_CONFIG"] = "1" + os.environ["HERMES_IGNORE_RULES"] = "1" + + def _set_chat_arg_defaults(args) -> None: for attr, default in [ ("query", None), diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index b95e8e3eeff3..452eb6b7941d 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -1283,10 +1283,6 @@ class PluginManager: """ if self._discovered and not force: return - # Safe mode (--safe-mode / HERMES_SAFE_MODE=1): troubleshooting run - # with all customizations disabled. Skip plugin discovery entirely so - # no third-party code (hooks, tools, platforms) loads. Mark as - # discovered so callers see a clean empty registry, not a retry loop. if env_var_enabled("HERMES_SAFE_MODE"): logger.info("HERMES_SAFE_MODE=1 — plugin discovery skipped") self._discovered = True diff --git a/tests/hermes_cli/test_safe_mode.py b/tests/hermes_cli/test_safe_mode.py index 2c52786facb7..6ab0cf533fa3 100644 --- a/tests/hermes_cli/test_safe_mode.py +++ b/tests/hermes_cli/test_safe_mode.py @@ -1,18 +1,10 @@ -"""Tests for `hermes chat --safe-mode` — pristine troubleshooting runs. - -Inspired by Claude Code v2.1.169's ``--safe-mode`` flag (June 2026), which -disables all customizations (CLAUDE.md, plugins, skills, hooks, MCP) for -troubleshooting. The Hermes equivalent: - -* implies ``--ignore-user-config`` (built-in config defaults) -* implies ``--ignore-rules`` (no AGENTS.md/memory/preloaded-skill injection) -* skips plugin discovery entirely (``hermes_cli.plugins``) -* loads zero MCP servers (``tools.mcp_tool._load_mcp_config``) -""" +"""Tests for `hermes chat --safe-mode` isolation.""" from __future__ import annotations import os +import sys +import types import pytest @@ -29,102 +21,112 @@ def _clean_env(monkeypatch): os.environ.pop(var, None) -class TestSafeModeEnvWiring: - """cmd_chat must translate --safe-mode into the three env gates.""" +def test_cmd_chat_safe_mode_sets_env_before_startup(monkeypatch): + import hermes_cli.main as main_mod + from hermes_cli._parser import build_top_level_parser - def test_safe_mode_sets_all_gates(self): - # Mirrors the cmd_chat logic in hermes_cli/main.py. - class Args: - safe_mode = True + parser, _subparsers, chat_parser = build_top_level_parser() + chat_parser.set_defaults(func=main_mod.cmd_chat) + args = parser.parse_args(["chat", "--safe-mode"]) + captured: dict[str, object] = {} + fake_cli = types.ModuleType("cli") - args = Args() - if getattr(args, "safe_mode", False): - os.environ["HERMES_SAFE_MODE"] = "1" - os.environ["HERMES_IGNORE_USER_CONFIG"] = "1" - os.environ["HERMES_IGNORE_RULES"] = "1" + def fake_has_provider() -> bool: + assert os.environ["HERMES_SAFE_MODE"] == "1" + assert os.environ["HERMES_IGNORE_USER_CONFIG"] == "1" + assert os.environ["HERMES_IGNORE_RULES"] == "1" + return True - assert os.environ.get("HERMES_SAFE_MODE") == "1" - assert os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1" - assert os.environ.get("HERMES_IGNORE_RULES") == "1" + def fake_main(**kwargs): + captured.update(kwargs) + + monkeypatch.setattr(main_mod, "_has_any_provider_configured", fake_has_provider) + monkeypatch.setattr(main_mod, "_pin_kanban_board_env", lambda: None) + monkeypatch.setattr(main_mod, "_sync_bundled_skills_for_startup", lambda: None) + monkeypatch.setattr(main_mod, "_termux_should_prefetch_update_check", lambda: False) + setattr(fake_cli, "main", fake_main) + monkeypatch.setitem(sys.modules, "cli", fake_cli) + + main_mod.cmd_chat(args) + + assert captured["ignore_user_config"] is True + assert captured["ignore_rules"] is True -class TestSafeModePluginDiscovery: - """Plugin discovery must be a no-op under HERMES_SAFE_MODE=1.""" +def test_prepare_agent_startup_applies_safe_mode_before_plugin_discovery(monkeypatch): + import hermes_cli.main as main_mod - def test_discovery_skipped(self, monkeypatch): - monkeypatch.setenv("HERMES_SAFE_MODE", "1") - from hermes_cli.plugins import PluginManager + args = types.SimpleNamespace(command="chat", safe_mode=True, tui=False) + plugins = types.ModuleType("hermes_cli.plugins") - mgr = PluginManager() - called = [] - monkeypatch.setattr( - mgr, "_discover_and_load_inner", lambda: called.append(True) - ) - mgr.discover_and_load() - assert called == [] # inner sweep never ran - assert mgr._discovered is True # registry settled as clean-empty - assert mgr._plugins == {} + def discover_plugins() -> None: + assert os.environ["HERMES_SAFE_MODE"] == "1" + assert os.environ["HERMES_IGNORE_USER_CONFIG"] == "1" + assert os.environ["HERMES_IGNORE_RULES"] == "1" - def test_discovery_runs_without_safe_mode(self, monkeypatch): - monkeypatch.delenv("HERMES_SAFE_MODE", raising=False) - from hermes_cli.plugins import PluginManager + setattr(plugins, "discover_plugins", discover_plugins) + monkeypatch.setitem(sys.modules, "hermes_cli.plugins", plugins) + monkeypatch.setattr(main_mod, "_should_background_mcp_startup", lambda _args: False) + monkeypatch.setattr(main_mod, "_command_has_dedicated_mcp_startup", lambda _args: True) - mgr = PluginManager() - called = [] - monkeypatch.setattr( - mgr, "_discover_and_load_inner", lambda: called.append(True) - ) - mgr.discover_and_load() - assert called == [True] + main_mod._prepare_agent_startup(args) -class TestSafeModeMCP: - """_load_mcp_config must return no servers under HERMES_SAFE_MODE=1.""" +def test_plugin_discovery_skipped(monkeypatch): + monkeypatch.setenv("HERMES_SAFE_MODE", "1") + from hermes_cli.plugins import PluginManager - def test_mcp_servers_empty(self, monkeypatch): - monkeypatch.setenv("HERMES_SAFE_MODE", "1") - from tools.mcp_tool import _load_mcp_config + mgr = PluginManager() + called = [] + monkeypatch.setattr(mgr, "_discover_and_load_inner", lambda: called.append(True)) - with pytest.MonkeyPatch.context() as mp: - mp.setattr( - "hermes_cli.config.load_config", - lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}}, - ) - assert _load_mcp_config() == {} + mgr.discover_and_load() - def test_mcp_servers_load_without_safe_mode(self, monkeypatch): - monkeypatch.delenv("HERMES_SAFE_MODE", raising=False) - from tools.mcp_tool import _load_mcp_config - - with pytest.MonkeyPatch.context() as mp: - mp.setattr( - "hermes_cli.config.load_config", - lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}}, - ) - servers = _load_mcp_config() - assert "github" in servers + assert called == [] + assert mgr._discovered is True + assert mgr._plugins == {} -class TestSafeModeParser: - """--safe-mode must parse on both the root parser and `hermes chat`.""" +def test_plugin_discovery_runs_without_safe_mode(monkeypatch): + from hermes_cli.plugins import PluginManager - def test_chat_subcommand_accepts_flag(self): - from hermes_cli._parser import build_top_level_parser + mgr = PluginManager() + called = [] + monkeypatch.setattr(mgr, "_discover_and_load_inner", lambda: called.append(True)) - parser, _subparsers, _chat = build_top_level_parser() - args = parser.parse_args(["chat", "--safe-mode"]) - assert getattr(args, "safe_mode", False) is True + mgr.discover_and_load() - def test_root_parser_accepts_flag(self): - from hermes_cli._parser import build_top_level_parser + assert called == [True] - parser, _subparsers, _chat = build_top_level_parser() - args = parser.parse_args(["--safe-mode"]) - assert getattr(args, "safe_mode", False) is True - def test_default_is_off(self): - from hermes_cli._parser import build_top_level_parser +def test_mcp_servers_empty(monkeypatch): + monkeypatch.setenv("HERMES_SAFE_MODE", "1") + from tools.mcp_tool import _load_mcp_config - parser, _subparsers, _chat = build_top_level_parser() - args = parser.parse_args(["chat"]) - assert getattr(args, "safe_mode", False) is False + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}}, + ) + + assert _load_mcp_config() == {} + + +def test_mcp_servers_load_without_safe_mode(monkeypatch): + from tools.mcp_tool import _load_mcp_config + + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}}, + ) + + assert "github" in _load_mcp_config() + + +def test_parser_accepts_safe_mode_on_root_and_chat(): + from hermes_cli._parser import build_top_level_parser + + parser, _subparsers, _chat = build_top_level_parser() + + assert parser.parse_args(["--safe-mode"]).safe_mode is True + assert parser.parse_args(["chat", "--safe-mode"]).safe_mode is True + assert parser.parse_args(["chat"]).safe_mode is False diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 71c8a635555b..329ddebdd73f 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -3531,9 +3531,8 @@ def _load_mcp_config() -> Dict[str, dict]: """ try: from hermes_cli.config import load_config - # Safe mode (--safe-mode / HERMES_SAFE_MODE=1): troubleshooting run - # with all customizations disabled — no MCP servers connect. from utils import env_var_enabled as _env_enabled + if _env_enabled("HERMES_SAFE_MODE"): return {} config = load_config() From 299d5c660343d879c5c6088f9591759d3130020e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:21:16 -0700 Subject: [PATCH 074/610] fix(cli): safe mode also skips shell-hook registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --safe-mode promised to disable ALL customizations, but shell hooks declared in config.yaml's hooks: block registered anyway — register_from_config() runs independently of plugin discovery and load_config() does not honor HERMES_IGNORE_USER_CONFIG. Gate it on HERMES_SAFE_MODE at the single chokepoint so troubleshooting runs fire zero user-configured code (plugins, MCP, and hooks). Docs (en + zh) updated; positive + negative tests added. --- agent/shell_hooks.py | 9 +++++ tests/hermes_cli/test_safe_mode.py | 37 +++++++++++++++++++ website/docs/reference/cli-commands.md | 2 +- .../docs/reference/environment-variables.md | 2 +- .../reference/environment-variables.md | 2 +- 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index 5292244e2540..c1cec81df333 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -224,6 +224,15 @@ def register_from_config( if not isinstance(cfg, dict): return [] + # Safe mode (--safe-mode / HERMES_SAFE_MODE=1): shell hooks are user + # customizations too — skip registration entirely so a troubleshooting + # run fires zero user-configured code (plugins, MCP, AND hooks). + from utils import env_var_enabled + + if env_var_enabled("HERMES_SAFE_MODE"): + logger.info("HERMES_SAFE_MODE=1 — shell-hook registration skipped") + return [] + effective_accept = _resolve_effective_accept(cfg, accept_hooks) specs = _parse_hooks_block(cfg.get("hooks")) diff --git a/tests/hermes_cli/test_safe_mode.py b/tests/hermes_cli/test_safe_mode.py index 6ab0cf533fa3..ffd0b85b86b0 100644 --- a/tests/hermes_cli/test_safe_mode.py +++ b/tests/hermes_cli/test_safe_mode.py @@ -130,3 +130,40 @@ def test_parser_accepts_safe_mode_on_root_and_chat(): assert parser.parse_args(["--safe-mode"]).safe_mode is True assert parser.parse_args(["chat", "--safe-mode"]).safe_mode is True assert parser.parse_args(["chat"]).safe_mode is False + + +def test_shell_hooks_skipped(monkeypatch): + monkeypatch.setenv("HERMES_SAFE_MODE", "1") + from agent.shell_hooks import register_from_config + + cfg = { + "hooks": { + "pre_tool_call": [{"command": "echo hooked"}], + }, + "hooks_auto_accept": True, + } + + assert register_from_config(cfg, accept_hooks=True) == [] + + +def test_shell_hooks_register_without_safe_mode(monkeypatch): + import agent.shell_hooks as sh + + cfg = { + "hooks": { + "pre_tool_call": [{"command": "echo hooked"}], + }, + "hooks_auto_accept": True, + } + + manager = types.SimpleNamespace(_hooks={}) + plugins = types.ModuleType("hermes_cli.plugins") + setattr(plugins, "get_plugin_manager", lambda: manager) + setattr(plugins, "VALID_HOOKS", {"pre_tool_call"}) + monkeypatch.setitem(sys.modules, "hermes_cli.plugins", plugins) + monkeypatch.setattr(sh, "_registered", set()) + + registered = sh.register_from_config(cfg, accept_hooks=True) + + assert len(registered) == 1 + assert "pre_tool_call" in manager._hooks diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 1bc4d4b131b8..b8ace5b1912a 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -117,7 +117,7 @@ Common options: | `--pass-session-id` | Pass the session ID into the system prompt. | | `--ignore-user-config` | Ignore `~/.hermes/config.yaml` and use built-in defaults. Credentials in `.env` are still loaded. Useful for isolated CI runs, reproducible bug reports, and third-party integrations. | | `--ignore-rules` | Skip auto-injection of `AGENTS.md`, `SOUL.md`, `.cursorrules`, persistent memory, and preloaded skills. Combine with `--ignore-user-config` for a fully isolated run. | -| `--safe-mode` | Troubleshooting mode: disable ALL customizations — user config, rules/memory injection, plugins, and MCP servers (implies `--ignore-user-config` and `--ignore-rules`). Use to isolate whether a problem comes from your setup or from Hermes itself. | +| `--safe-mode` | Troubleshooting mode: disable ALL customizations — user config, rules/memory injection, plugins, shell hooks, and MCP servers (implies `--ignore-user-config` and `--ignore-rules`). Use to isolate whether a problem comes from your setup or from Hermes itself. | | `--source ` | Session source tag for filtering (default: `cli`). Use `tool` for third-party integrations that should not appear in user session lists. | | `--max-turns ` | Maximum tool-calling iterations per conversation turn (default: 90, or `agent.max_turns` in config). | diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 9f4aa2144539..9e95d90e35e4 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -716,7 +716,7 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us | `HERMES_ACCEPT_HOOKS` | Auto-approve any unseen shell hooks declared in `config.yaml` without a TTY prompt. Equivalent to `--accept-hooks` or `hooks_auto_accept: true`. | | `HERMES_IGNORE_USER_CONFIG` | Skip `~/.hermes/config.yaml` and use built-in defaults (credentials in `.env` still load). Equivalent to `--ignore-user-config`. | | `HERMES_IGNORE_RULES` | Skip auto-injection of `AGENTS.md`, `SOUL.md`, `.cursorrules`, memory, and preloaded skills. Equivalent to `--ignore-rules`. | -| `HERMES_SAFE_MODE` | Troubleshooting mode: disable ALL customizations — skips plugin discovery and MCP server loading. Set automatically by `--safe-mode` (which also sets the two flags above). | +| `HERMES_SAFE_MODE` | Troubleshooting mode: disable ALL customizations — skips plugin discovery, MCP server loading, and shell-hook registration. Set automatically by `--safe-mode` (which also sets the two flags above). | | `HERMES_MD_NAMES` | Comma-separated list of rules-file names to auto-inject (default: `AGENTS.md,CLAUDE.md,.cursorrules,SOUL.md`). | | `HERMES_TOOL_PROGRESS` | Deprecated compatibility variable for tool progress display. Prefer `display.tool_progress` in `config.yaml`. | | `HERMES_TOOL_PROGRESS_MODE` | Deprecated compatibility variable for tool progress mode. Prefer `display.tool_progress` in `config.yaml`. | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md index 7ee79f765112..8de0ad1a93ec 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md @@ -532,7 +532,7 @@ Graph 事件(Teams 会议、日历、聊天等)的入站变更通知监听 | `HERMES_ACCEPT_HOOKS` | 无需 TTY 提示自动批准 `config.yaml` 中声明的任何未见过的 shell hook。等同于 `--accept-hooks` 或 `hooks_auto_accept: true`。 | | `HERMES_IGNORE_USER_CONFIG` | 跳过 `~/.hermes/config.yaml` 并使用内置默认值(`.env` 中的凭证仍会加载)。等同于 `--ignore-user-config`。 | | `HERMES_IGNORE_RULES` | 跳过 `AGENTS.md`、`SOUL.md`、`.cursorrules`、记忆和预加载技能的自动注入。等同于 `--ignore-rules`。 | -| `HERMES_SAFE_MODE` | 故障排查模式:禁用**所有**自定义项——跳过插件发现和 MCP 服务器加载。由 `--safe-mode` 自动设置(同时也会设置上面两个 flag)。 | +| `HERMES_SAFE_MODE` | 故障排查模式:禁用**所有**自定义项——跳过插件发现、MCP 服务器加载和 shell hook 注册。由 `--safe-mode` 自动设置(同时也会设置上面两个 flag)。 | | `HERMES_MD_NAMES` | 自动注入的规则文件名逗号分隔列表(默认:`AGENTS.md,CLAUDE.md,.cursorrules,SOUL.md`)。 | | `HERMES_TOOL_PROGRESS` | 工具进度显示的已弃用兼容变量。优先使用 `config.yaml` 中的 `display.tool_progress`。 | | `HERMES_TOOL_PROGRESS_MODE` | 工具进度模式的已弃用兼容变量。优先使用 `config.yaml` 中的 `display.tool_progress`。 | From c0adfd4a67ca1db66ae13724d2d1286b66e4147f Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:20:48 -0700 Subject: [PATCH 075/610] feat(desktop): render Mermaid code blocks in markdown file preview Salvaged from #40531; surgically reapplied onto current main (i18n'd preview-file.tsx). mermaid dep already present on main. Co-authored-by: liuhao1024 --- .../src/app/chat/right-rail/preview-file.tsx | 16 +++- .../src/components/chat/mermaid-block.tsx | 84 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/components/chat/mermaid-block.tsx diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index 408e9d86b88b..48e0649647ad 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -6,7 +6,7 @@ import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react' -import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Fragment, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import ShikiHighlighter from 'react-shiki' import { Streamdown } from 'streamdown' @@ -34,6 +34,10 @@ import { setPreviewDirty } from '@/store/preview-edit' import { $currentCwd } from '@/store/session' import { notifyWorkspaceChanged } from '@/store/workspace-events' +const MermaidDiagram = lazy(() => + import('@/components/chat/mermaid-block').then(m => ({ default: m.MermaidBlock })) +) + const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const const TEXT_PREVIEW_MAX_BYTES = 512 * 1024 const SOURCE_CHUNK_LINES = 200 @@ -291,6 +295,16 @@ function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>) ) } + if (language === 'mermaid') { + return ( + Loading diagram…
} + > + + + ) + } + return ( | null = null + +function loadMermaid() { + if (!mermaidPromise) { + mermaidPromise = import('mermaid').then(async mod => { + const m = mod.default + + m.initialize({ + securityLevel: 'strict', + startOnLoad: false, + theme: document.documentElement.classList.contains('dark') ? 'dark' : 'default' + }) + + return mod + }) + } + + return mermaidPromise +} + +interface MermaidBlockProps { + chart: string +} + +export function MermaidBlock({ chart }: MermaidBlockProps) { + const containerRef = useRef(null) + const [error, setError] = useState(null) + const [svg, setSvg] = useState(null) + + useEffect(() => { + let cancelled = false + + async function render() { + try { + const { default: mermaid } = await loadMermaid() + const id = `mermaid-${Math.random().toString(36).slice(2, 10)}` + const { svg: rendered } = await mermaid.render(id, chart) + + if (!cancelled) { + setSvg(rendered) + setError(null) + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)) + setSvg(null) + } + } + } + + void render() + + return () => { + cancelled = true + } + }, [chart]) + + if (error) { + return ( +
+
Mermaid diagram error
+
{error}
+
+ View source +
{chart}
+
+
+ ) + } + + if (!svg) { + return
Rendering diagram…
+ } + + return ( +
+ ) +} From 7ff86f4458dd7547cab4392686896cbe5fb649a1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:13:58 -0700 Subject: [PATCH 076/610] refactor(desktop): route preview-pane mermaid fences through shared embeds registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the duplicate mermaid-block.tsx (own mermaid.initialize + render path, theme frozen at first load) and wire preview-file.tsx's MarkdownCode through the existing RichCodeBlock registry from #52935 instead. One mermaid init path, theme-flip re-init, Zoomable + copy-as-PNG, RichBoundary error fallback — and the preview pane gets svg fences for free. Shiki block stays as the fallback for all other languages. --- .../src/app/chat/right-rail/preview-file.tsx | 25 ++---- .../src/components/chat/mermaid-block.tsx | 84 ------------------- 2 files changed, 9 insertions(+), 100 deletions(-) delete mode 100644 apps/desktop/src/components/chat/mermaid-block.tsx diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index 48e0649647ad..ca4c65f2e998 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -6,7 +6,7 @@ import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react' -import { Fragment, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import ShikiHighlighter from 'react-shiki' import { Streamdown } from 'streamdown' @@ -14,6 +14,7 @@ import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/comp import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs' import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions' import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection' +import { RichCodeBlock } from '@/components/assistant-ui/embeds' import { CodeEditor } from '@/components/chat/code-editor' import { FileDiffPanel } from '@/components/chat/diff-lines' import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window' @@ -34,10 +35,6 @@ import { setPreviewDirty } from '@/store/preview-edit' import { $currentCwd } from '@/store/session' import { notifyWorkspaceChanged } from '@/store/workspace-events' -const MermaidDiagram = lazy(() => - import('@/components/chat/mermaid-block').then(m => ({ default: m.MermaidBlock })) -) - const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const const TEXT_PREVIEW_MAX_BYTES = 512 * 1024 const SOURCE_CHUNK_LINES = 200 @@ -295,17 +292,9 @@ function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>) ) } - if (language === 'mermaid') { - return ( - Loading diagram…
} - > - - - ) - } + const code = String(children).replace(/\n$/, '') - return ( + const highlighted = ( ) showLanguage={false} theme={SHIKI_THEME} > - {String(children).replace(/\n$/, '')} + {code} ) + + // ```mermaid / ```svg fences route to the shared lazy renderers (same + // registry the chat transcript uses); everything else stays on Shiki. + return } const MARKDOWN_COMPONENTS = { diff --git a/apps/desktop/src/components/chat/mermaid-block.tsx b/apps/desktop/src/components/chat/mermaid-block.tsx deleted file mode 100644 index 157dc0d2e036..000000000000 --- a/apps/desktop/src/components/chat/mermaid-block.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useEffect, useRef, useState } from 'react' - -let mermaidPromise: Promise | null = null - -function loadMermaid() { - if (!mermaidPromise) { - mermaidPromise = import('mermaid').then(async mod => { - const m = mod.default - - m.initialize({ - securityLevel: 'strict', - startOnLoad: false, - theme: document.documentElement.classList.contains('dark') ? 'dark' : 'default' - }) - - return mod - }) - } - - return mermaidPromise -} - -interface MermaidBlockProps { - chart: string -} - -export function MermaidBlock({ chart }: MermaidBlockProps) { - const containerRef = useRef(null) - const [error, setError] = useState(null) - const [svg, setSvg] = useState(null) - - useEffect(() => { - let cancelled = false - - async function render() { - try { - const { default: mermaid } = await loadMermaid() - const id = `mermaid-${Math.random().toString(36).slice(2, 10)}` - const { svg: rendered } = await mermaid.render(id, chart) - - if (!cancelled) { - setSvg(rendered) - setError(null) - } - } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err.message : String(err)) - setSvg(null) - } - } - } - - void render() - - return () => { - cancelled = true - } - }, [chart]) - - if (error) { - return ( -
-
Mermaid diagram error
-
{error}
-
- View source -
{chart}
-
-
- ) - } - - if (!svg) { - return
Rendering diagram…
- } - - return ( -
- ) -} From a796e0b79632bad8df19062e149f30a540f883a0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:39:31 -0700 Subject: [PATCH 077/610] fix: cool down transient Telegram typing failures (#46355) * fix: cool down transient Telegram typing failures Port from openclaw/openclaw#93020: add per-chat cooldown for transient sendChatAction failures so keep-typing refreshes do not hammer Telegram during network blips or rate limits. * fix: support bare Telegram adapters in typing cooldown * test: update typing backoff imports for relocated Telegram adapter The Telegram adapter moved from gateway/platforms/telegram.py to plugins/platforms/telegram/adapter.py since this branch was created; point the test imports and monkeypatch targets at the new module. --- plugins/platforms/telegram/adapter.py | 146 ++++++++++++++---- tests/gateway/test_telegram_typing_backoff.py | 113 ++++++++++++++ 2 files changed, 227 insertions(+), 32 deletions(-) create mode 100644 tests/gateway/test_telegram_typing_backoff.py diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 40f1ab094238..f3068018b070 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -524,6 +524,17 @@ class TelegramAdapter(BasePlatformAdapter): # endpoint) so later sends skip the doomed rich attempt entirely. self._rich_send_disabled: bool = False self._rich_draft_disabled: bool = False + # Transient Telegram sendChatAction failures (network blips, 429/5xx) + # can happen on every keep-typing tick while the agent is waiting on a + # long model call. Back off per chat so a short Telegram-side outage + # does not spam the API/logs or burn the keep-typing budget. + self._telegram_typing_cooldown_until: Dict[str, float] = {} + self._telegram_typing_cooldown_seconds: float = self._coerce_float_extra( + "typing_cooldown_seconds", + 30.0, + min_value=1.0, + max_value=300.0, + ) # Buffer rapid/album photo updates so Telegram image bursts are handled # as a single MessageEvent instead of self-interrupting multiple turns. self._media_batch_delay_seconds = env_float("HERMES_TELEGRAM_MEDIA_BATCH_DELAY_SECONDS", 0.8) @@ -1246,6 +1257,27 @@ class TelegramAdapter(BasePlatformAdapter): return default return bool(value) + def _coerce_float_extra( + self, + key: str, + default: float, + *, + min_value: Optional[float] = None, + max_value: Optional[float] = None, + ) -> float: + value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None + if value is None: + return default + try: + parsed = float(value) + except (TypeError, ValueError): + return default + if min_value is not None: + parsed = max(parsed, min_value) + if max_value is not None: + parsed = min(parsed, max_value) + return parsed + def _link_preview_kwargs(self) -> Dict[str, Any]: if not getattr(self, "_disable_link_previews", False): return {} @@ -6291,40 +6323,90 @@ class TelegramAdapter(BasePlatformAdapter): # Fallback: try as a regular photo return await self.send_image(chat_id, animation_url, caption, reply_to, metadata=metadata) + @staticmethod + def _is_transient_typing_error(exc: Exception) -> bool: + """Return True for Telegram typing errors worth cooling down.""" + retry_after = getattr(exc, "retry_after", None) + if retry_after is not None: + return True + + status_code = getattr(exc, "status_code", None) or getattr(exc, "code", None) + if isinstance(status_code, int) and (status_code == 429 or status_code >= 500): + return True + + text = str(exc).lower() + if any(marker in text for marker in ("too many requests", "rate limit", "timed out", "timeout", "temporar")): + return True + if isinstance(exc, (OSError, TimeoutError, ConnectionError, asyncio.TimeoutError)): + return True + return False + + def _record_typing_cooldown(self, chat_id: str, exc: Exception) -> None: + """Suppress Telegram typing refreshes for this chat after transient failures.""" + if not hasattr(self, "_telegram_typing_cooldown_until"): + self._telegram_typing_cooldown_until = {} + loop = asyncio.get_running_loop() + retry_after = getattr(exc, "retry_after", None) + try: + delay = float(retry_after) if retry_after is not None else self._telegram_typing_cooldown_seconds + except (TypeError, ValueError): + delay = self._telegram_typing_cooldown_seconds + delay = max(1.0, min(delay, 300.0)) + self._telegram_typing_cooldown_until[str(chat_id)] = loop.time() + delay + + def _typing_in_cooldown(self, chat_id: str) -> bool: + if not hasattr(self, "_telegram_typing_cooldown_until"): + self._telegram_typing_cooldown_until = {} + self._telegram_typing_cooldown_seconds = 30.0 + until = self._telegram_typing_cooldown_until.get(str(chat_id)) + if until is None: + return False + if asyncio.get_running_loop().time() < until: + return True + self._telegram_typing_cooldown_until.pop(str(chat_id), None) + return False + async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None: """Send typing indicator.""" - if self._bot: - _is_dm_topic: bool = False - message_thread_id: Optional[int] = None - try: - _typing_thread = self._metadata_thread_id(metadata) - _is_dm_topic = bool(metadata and metadata.get("telegram_dm_topic_reply_fallback")) - message_thread_id = self._message_thread_id_for_typing(_typing_thread) - await self._bot.send_chat_action( - chat_id=normalize_telegram_chat_id(chat_id), - action="typing", - message_thread_id=message_thread_id, - ) - except Exception as e: - # For DM topic lanes, Telegram may reject message_thread_id. - # Fall back to sending typing without thread_id so the typing - # indicator at least appears in the main DM view. - if _is_dm_topic and message_thread_id is not None: - try: - await self._bot.send_chat_action( - chat_id=normalize_telegram_chat_id(chat_id), - action="typing", - ) - return - except Exception: - pass - # Typing failures are non-fatal; log at debug level only. - logger.debug( - "[%s] Failed to send Telegram typing indicator: %s", - self.name, - e, - exc_info=True, - ) + if not self._bot or self._typing_in_cooldown(chat_id): + return + + _is_dm_topic: bool = False + message_thread_id: Optional[int] = None + try: + _typing_thread = self._metadata_thread_id(metadata) + _is_dm_topic = bool(metadata and metadata.get("telegram_dm_topic_reply_fallback")) + message_thread_id = self._message_thread_id_for_typing(_typing_thread) + await self._bot.send_chat_action( + chat_id=normalize_telegram_chat_id(chat_id), + action="typing", + message_thread_id=message_thread_id, + ) + self._telegram_typing_cooldown_until.pop(str(chat_id), None) + except Exception as e: + # For DM topic lanes, Telegram may reject message_thread_id. + # Fall back to sending typing without thread_id so the typing + # indicator at least appears in the main DM view. + if _is_dm_topic and message_thread_id is not None: + try: + await self._bot.send_chat_action( + chat_id=normalize_telegram_chat_id(chat_id), + action="typing", + ) + self._telegram_typing_cooldown_until.pop(str(chat_id), None) + return + except Exception as fallback_exc: + if self._is_transient_typing_error(fallback_exc): + self._record_typing_cooldown(chat_id, fallback_exc) + elif self._is_transient_typing_error(e): + self._record_typing_cooldown(chat_id, e) + # Typing failures are non-fatal; log at debug level only. + logger.debug( + "[%s] Failed to send Telegram typing indicator: %s", + self.name, + e, + exc_info=True, + ) async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Get information about a Telegram chat.""" diff --git a/tests/gateway/test_telegram_typing_backoff.py b/tests/gateway/test_telegram_typing_backoff.py new file mode 100644 index 000000000000..efaa2b95de2e --- /dev/null +++ b/tests/gateway/test_telegram_typing_backoff.py @@ -0,0 +1,113 @@ +"""Telegram typing indicator transient backoff tests.""" + +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +_repo = str(Path(__file__).resolve().parents[2]) +if _repo not in sys.path: + sys.path.insert(0, _repo) + + +def _ensure_telegram_mock(): + if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): + return + + mod = MagicMock() + mod.ext.ContextTypes.DEFAULT_TYPE = type(None) + mod.constants.ParseMode.MARKDOWN = "Markdown" + mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" + mod.constants.ParseMode.HTML = "HTML" + mod.constants.ChatType.PRIVATE = "private" + mod.constants.ChatType.GROUP = "group" + mod.constants.ChatType.SUPERGROUP = "supergroup" + mod.constants.ChatType.CHANNEL = "channel" + mod.error.NetworkError = type("NetworkError", (OSError,), {}) + mod.error.TimedOut = type("TimedOut", (OSError,), {}) + mod.error.RetryAfter = type("RetryAfter", (Exception,), {"__init__": lambda self, retry_after=1: setattr(self, "retry_after", retry_after)}) + mod.error.BadRequest = type("BadRequest", (Exception,), {}) + + for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): + sys.modules.setdefault(name, mod) + sys.modules.setdefault("telegram.error", mod.error) + + +_ensure_telegram_mock() + +from gateway.config import PlatformConfig +from plugins.platforms.telegram.adapter import TelegramAdapter + + +def _make_adapter(): + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="test-token")) + adapter._bot = AsyncMock() + return adapter + + +@pytest.mark.asyncio +async def test_typing_transient_failure_enters_cooldown(monkeypatch): + adapter = _make_adapter() + now = {"value": 1000.0} + monkeypatch.setattr("plugins.platforms.telegram.adapter.asyncio.get_running_loop", lambda: type("Loop", (), {"time": lambda self: now["value"]})()) + monkeypatch.setattr(adapter, "_telegram_typing_cooldown_seconds", 30.0, raising=False) + + async def fail_once(**kwargs): + raise OSError("temporary telegram network failure") + + adapter._bot.send_chat_action = AsyncMock(side_effect=fail_once) + + await adapter.send_typing("123") + await adapter.send_typing("123") + + assert adapter._bot.send_chat_action.await_count == 1 + assert adapter._telegram_typing_cooldown_until["123"] == pytest.approx(1030.0) + + now["value"] = 1031.0 + adapter._bot.send_chat_action = AsyncMock(return_value=None) + await adapter.send_typing("123") + + assert adapter._bot.send_chat_action.await_count == 1 + assert "123" not in adapter._telegram_typing_cooldown_until + + +@pytest.mark.asyncio +async def test_typing_dm_topic_fallback_success_does_not_cool_down(monkeypatch): + adapter = _make_adapter() + monkeypatch.setattr("plugins.platforms.telegram.adapter.asyncio.get_running_loop", lambda: type("Loop", (), {"time": lambda self: 10.0})()) + + calls = [] + + async def send_chat_action(**kwargs): + calls.append(kwargs) + if "message_thread_id" in kwargs: + raise RuntimeError("message thread not found") + return None + + adapter._bot.send_chat_action = AsyncMock(side_effect=send_chat_action) + + await adapter.send_typing( + "123", + metadata={"thread_id": "99", "telegram_dm_topic_reply_fallback": True}, + ) + + assert len(calls) == 2 + assert "123" not in adapter._telegram_typing_cooldown_until + + +@pytest.mark.asyncio +async def test_typing_bad_thread_failure_does_not_cool_down(monkeypatch): + adapter = _make_adapter() + monkeypatch.setattr("plugins.platforms.telegram.adapter.asyncio.get_running_loop", lambda: type("Loop", (), {"time": lambda self: 10.0})()) + + async def bad_request(**kwargs): + raise ValueError("message thread not found") + + adapter._bot.send_chat_action = AsyncMock(side_effect=bad_request) + + await adapter.send_typing("123", metadata={"thread_id": "99"}) + await adapter.send_typing("123", metadata={"thread_id": "99"}) + + assert adapter._bot.send_chat_action.await_count == 2 + assert "123" not in adapter._telegram_typing_cooldown_until From 8fc1cb754b7658c9e1f4121e102854e3fa9563dc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:39:36 -0700 Subject: [PATCH 078/610] fix: repair URL authority whitespace before web fetches (#46363) Port from openclaw/openclaw#91950: normalize LLM-generated URLs like 'https:// docs.example' before web tool safety checks while preserving path and query encoding semantics. --- tests/tools/test_url_safety.py | 24 ++++++++++++++++++++++++ tools/url_safety.py | 8 ++++++++ 2 files changed, 32 insertions(+) diff --git a/tests/tools/test_url_safety.py b/tests/tools/test_url_safety.py index 1745d98d026d..b3386f092a6f 100644 --- a/tests/tools/test_url_safety.py +++ b/tests/tools/test_url_safety.py @@ -43,6 +43,30 @@ class TestNormalizeUrlForRequest: == "https://xn--mnich-kva.example/K%C3%B6ln" ) + def test_repairs_space_between_scheme_and_authority(self): + assert ( + normalize_url_for_request("https:// docs.openclaw.ai") + == "https://docs.openclaw.ai" + ) + + def test_repairs_tab_between_scheme_and_authority(self): + assert ( + normalize_url_for_request("https:// docs.openclaw.ai/path") + == "https://docs.openclaw.ai/path" + ) + + def test_trims_but_preserves_path_and_query_space_semantics(self): + assert ( + normalize_url_for_request(" https://example.com/a b?q=c d ") + == "https://example.com/a%20b?q=c%20d" + ) + + def test_does_not_collapse_embedded_scheme_separator_in_query(self): + assert ( + normalize_url_for_request("https://example.com/r?next=https:// evil.example") + == "https://example.com/r?next=https://%20evil.example" + ) + class TestIsSafeUrl: def test_public_url_allowed(self): diff --git a/tools/url_safety.py b/tools/url_safety.py index e81f10611984..d7a7d125ef3c 100644 --- a/tools/url_safety.py +++ b/tools/url_safety.py @@ -28,6 +28,7 @@ import logging import os import socket import asyncio +import re from typing import Any, Optional from urllib.parse import parse_qsl, quote, unquote, urljoin, urlparse, urlsplit, urlunsplit @@ -52,6 +53,13 @@ def normalize_url_for_request(url: str) -> str: if not raw: return raw + # Models sometimes emit otherwise valid URLs with whitespace between the + # scheme separator and authority (``https:// docs.example``). That position + # is never meaningful in HTTP(S) URLs, and repairing it before parsing keeps + # web tools from failing on a formatting artifact while leaving path/query + # whitespace to the normal percent-encoding path below. + raw = re.sub(r"^([A-Za-z][A-Za-z0-9+.-]*://)\s+", r"\1", raw) + try: parsed = urlsplit(raw) except ValueError: From d1c8c03416d7c6780d31ce2371c5b0777f10afaf Mon Sep 17 00:00:00 2001 From: hmirin Date: Sun, 28 Jun 2026 01:35:40 +0900 Subject: [PATCH 079/610] feat(agent): add Codex-native compaction paths --- agent/agent_init.py | 11 + agent/codex_runtime.py | 71 +++++++ agent/conversation_compression.py | 131 ++++++++++++ agent/transports/codex_app_server_session.py | 183 ++++++++++++++++ agent/turn_context.py | 20 ++ cli-config.yaml.example | 22 ++ hermes_cli/config.py | 8 + scripts/release.py | 1 + .../test_codex_app_server_session.py | 101 +++++++++ tests/gateway/test_agent_cache.py | 9 +- tests/hermes_cli/test_config.py | 51 +++++ .../test_codex_app_server_compaction.py | 196 ++++++++++++++++++ .../test_codex_app_server_integration.py | 43 +++- .../context-compression-and-caching.md | 24 +++ 14 files changed, 869 insertions(+), 2 deletions(-) create mode 100644 tests/run_agent/test_codex_app_server_compaction.py diff --git a/agent/agent_init.py b/agent/agent_init.py index 32a062594411..495260d21886 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1570,6 +1570,16 @@ def init_agent( compression_in_place = is_truthy_value( _compression_cfg.get("in_place"), default=False ) + codex_app_server_auto_compaction = str( + _compression_cfg.get("codex_app_server_auto", "native") or "native" + ).lower() + if codex_app_server_auto_compaction not in {"native", "hermes", "off"}: + _ra().logger.warning( + "Invalid compression.codex_app_server_auto=%r; using 'native'. " + "Valid values are: native, hermes, off.", + codex_app_server_auto_compaction, + ) + codex_app_server_auto_compaction = "native" # Read optional explicit context_length override for the auxiliary # compression model. Custom endpoints often cannot report this via @@ -1824,6 +1834,7 @@ def init_agent( pass agent.compression_enabled = compression_enabled agent.compression_in_place = compression_in_place + agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction # Reject models whose context window is below the minimum required # for reliable tool-calling workflows (64K tokens). diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 1cf48ec17051..2077d2fddcab 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -228,6 +228,76 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]: } +def _record_codex_app_server_compaction( + agent, + turn, + *, + approx_tokens: int | None = None, + force: bool = False, +) -> bool: + """Record a Codex-native context compaction boundary in Hermes state. + + The app-server owns the compacted thread context, so Hermes should not + rewrite local transcript rows here; state.db records the boundary via the + session event/usage counters while preserving the visible transcript. + """ + if not force and not getattr(turn, "compacted", False): + return False + + thread_id = getattr(turn, "thread_id", None) or "" + turn_id = getattr(turn, "turn_id", None) or "" + logger.info( + "codex app-server compaction observed: session=%s thread=%s turn=%s force=%s", + getattr(agent, "session_id", None) or "none", + thread_id, + turn_id, + force, + ) + if not force: + try: + from agent.conversation_compression import COMPACTION_STATUS + + agent._emit_status(COMPACTION_STATUS) + except Exception: + pass + + compressor = getattr(agent, "context_compressor", None) + if compressor is not None: + compressor.compression_count = getattr( + compressor, "compression_count", 0 + ) + 1 + compressor.last_compression_rough_tokens = approx_tokens or 0 + if not getattr(turn, "token_usage_last", None): + compressor.last_prompt_tokens = -1 + compressor.last_completion_tokens = 0 + compressor.awaiting_real_usage_after_compression = True + + agent._last_compaction_in_place = False + try: + if getattr(agent, "event_callback", None): + agent.event_callback( + "session:compress", + { + "platform": getattr(agent, "platform", None) or "", + "session_id": getattr(agent, "session_id", None) or "", + "old_session_id": "", + "in_place": False, + "compression_count": getattr( + compressor, "compression_count", 0 + ) + if compressor is not None + else 0, + "runtime": "codex_app_server", + "thread_id": thread_id, + "turn_id": turn_id, + }, + ) + except Exception: + logger.debug("event_callback error on codex session:compress", exc_info=True) + + return True + + def run_codex_app_server_turn( agent, *, @@ -393,6 +463,7 @@ def run_codex_app_server_turn( agent._iters_since_skill = ( getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations ) + _record_codex_app_server_compaction(agent, turn) usage_result = _record_codex_app_server_usage(agent, turn) api_calls = 1 diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 7e7a26dc2ed5..843960cc2818 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -465,6 +465,21 @@ def compress_context( prompt — the session is NOT rotated. Callers should detect the no-op via ``len(returned) == len(input)`` and stop the retry loop. """ + # Codex app-server sessions: the codex agent owns the real thread context; + # Hermes' summarizer would only rewrite a local mirror without shrinking + # the actual thread (#36801). Route compaction to the app server's own + # thread/compact mechanism. Behavior is controlled by + # ``compression.codex_app_server_auto`` (native|hermes|off). + if getattr(agent, "api_mode", None) == "codex_app_server": + return _compress_context_via_codex_app_server( + agent, + messages, + system_message, + approx_tokens=approx_tokens, + task_id=task_id, + force=force, + ) + # Lazy feasibility check — run the auxiliary-provider probe + context # length lookup just-in-time on the first compression attempt instead of # at AIAgent.__init__. Saves ~400ms cold off every short session that @@ -971,6 +986,122 @@ def compress_context( _release_lock() +def _compress_context_via_codex_app_server( + agent: Any, + messages: list, + system_message: Optional[str], + *, + approx_tokens: Optional[int] = None, + task_id: str = "default", + force: bool = False, +) -> Tuple[list, str]: + """Route compaction to Codex app-server for Codex-owned threads. + + Hermes' normal compressor rewrites the local OpenAI-style transcript. + That does not shrink the actual Codex app-server thread context. For this + runtime, ask Codex to compact its own thread and keep Hermes' transcript + unchanged. + """ + auto_mode = str( + getattr(agent, "codex_app_server_auto_compaction", "native") or "native" + ).lower() + if auto_mode not in {"native", "hermes", "off"}: + auto_mode = "native" + if not force and auto_mode != "hermes": + logger.info( + "codex app-server compaction skipped: mode=%s force=false " + "(session=%s messages=%d tokens=~%s)", + auto_mode, + getattr(agent, "session_id", None) or "none", + len(messages), + f"{approx_tokens:,}" if approx_tokens else "unknown", + ) + existing_prompt = getattr(agent, "_cached_system_prompt", None) + if not existing_prompt: + existing_prompt = agent._build_system_prompt(system_message) + return messages, existing_prompt + + codex_session = getattr(agent, "_codex_session", None) + if codex_session is None: + logger.info( + "codex app-server compaction skipped: no active codex thread " + "(session=%s messages=%d tokens=~%s)", + getattr(agent, "session_id", None) or "none", + len(messages), + f"{approx_tokens:,}" if approx_tokens else "unknown", + ) + existing_prompt = getattr(agent, "_cached_system_prompt", None) + if not existing_prompt: + existing_prompt = agent._build_system_prompt(system_message) + return messages, existing_prompt + + logger.info( + "codex app-server compaction started: session=%s messages=%d tokens=~%s", + getattr(agent, "session_id", None) or "none", + len(messages), + f"{approx_tokens:,}" if approx_tokens else "unknown", + ) + try: + agent._emit_status(COMPACTION_STATUS) + except Exception: + pass + + result = codex_session.compact_thread() + if getattr(result, "should_retire", False): + try: + codex_session.close() + except Exception: + pass + agent._codex_session = None + + if getattr(result, "error", None): + try: + agent._emit_warning( + f"⚠ Codex app-server compaction failed: {result.error}" + ) + except Exception: + pass + existing_prompt = getattr(agent, "_cached_system_prompt", None) + if not existing_prompt: + existing_prompt = agent._build_system_prompt(system_message) + return messages, existing_prompt + + try: + from agent.codex_runtime import ( + _record_codex_app_server_compaction, + _record_codex_app_server_usage, + ) + + _record_codex_app_server_compaction( + agent, + result, + approx_tokens=approx_tokens, + force=True, + ) + if getattr(result, "token_usage_last", None): + _record_codex_app_server_usage(agent, result) + except Exception: + logger.debug("codex compaction bookkeeping failed", exc_info=True) + + try: + from tools.file_tools import reset_file_dedup + + reset_file_dedup(task_id) + except Exception: + pass + + logger.info( + "codex app-server compaction done: session=%s thread=%s turn=%s", + getattr(agent, "session_id", None) or "none", + getattr(result, "thread_id", None) or "", + getattr(result, "turn_id", None) or "", + ) + existing_prompt = getattr(agent, "_cached_system_prompt", None) + if not existing_prompt: + existing_prompt = agent._build_system_prompt(system_message) + return messages, existing_prompt + + def try_shrink_image_parts_in_messages( api_messages: list, *, diff --git a/agent/transports/codex_app_server_session.py b/agent/transports/codex_app_server_session.py index 7292823766e9..78af728711dd 100644 --- a/agent/transports/codex_app_server_session.py +++ b/agent/transports/codex_app_server_session.py @@ -75,6 +75,7 @@ class TurnResult: token_usage_last: Optional[dict[str, Any]] = None token_usage_total: Optional[dict[str, Any]] = None model_context_window: Optional[int] = None + compacted: bool = False # Hint to the caller that the underlying codex subprocess is likely # wedged (turn-level timeout fired, post-tool watchdog tripped, or # token-refresh failure killed the child). The caller should retire @@ -505,6 +506,7 @@ class CodexAppServerSession: if pending is None: break _apply_token_usage_notification(result, pending) + _apply_compaction_notification(result, pending) self._track_pending_file_change(pending) proj = projector.project(pending) if proj.messages: @@ -541,6 +543,7 @@ class CodexAppServerSession: logger.debug("on_event callback raised", exc_info=True) _apply_token_usage_notification(result, note) + _apply_compaction_notification(result, note) # Track in-progress fileChange items so the approval bridge # can surface a real change summary when codex requests @@ -632,6 +635,154 @@ class CodexAppServerSession: return result + def compact_thread( + self, + *, + turn_timeout: float = 600.0, + notification_poll_timeout: float = 0.25, + ) -> TurnResult: + """Trigger Codex-native history compaction for the current thread. + + `thread/compact/start` returns immediately; the actual compaction + progress streams through the same turn/item notifications as a normal + turn. We wait for the matching `turn/completed` so callers can treat a + successful return as a completed compaction boundary. + """ + result = TurnResult() + try: + self.ensure_started() + except (CodexAppServerError, TimeoutError) as exc: + result.error = self._format_error_with_stderr( + "codex app-server startup failed", exc + ) + result.should_retire = True + return result + + assert self._client is not None and self._thread_id is not None + result.thread_id = self._thread_id + self._interrupt_event.clear() + projector = CodexEventProjector() + + try: + self._client.request( + "thread/compact/start", + {"threadId": self._thread_id}, + timeout=10, + ) + except CodexAppServerError as exc: + stderr_blob = "\n".join(self._client.stderr_tail(40)) + hint = _classify_oauth_failure(exc.message, stderr_blob) + if hint is not None: + result.error = hint + result.should_retire = True + else: + result.error = self._format_error_with_stderr( + "thread/compact/start failed", exc + ) + return result + except TimeoutError as exc: + stderr_blob = "\n".join(self._client.stderr_tail(40)) + hint = _classify_oauth_failure(stderr_blob) + result.error = hint or self._format_error_with_stderr( + "thread/compact/start timed out", exc + ) + result.should_retire = True + return result + + deadline = time.monotonic() + turn_timeout + turn_complete = False + + while time.monotonic() < deadline and not turn_complete: + if self._interrupt_event.is_set(): + self._issue_interrupt(result.turn_id) + result.interrupted = True + break + + if not self._client.is_alive(): + stderr_blob = "\n".join(self._client.stderr_tail(60)) + hint = _classify_oauth_failure(stderr_blob) + if hint is not None: + result.error = hint + else: + result.error = self._format_error_with_stderr( + "codex app-server subprocess exited unexpectedly", + tail_lines=20, + ) + result.should_retire = True + break + + sreq = self._client.take_server_request(timeout=0) + if sreq is not None: + self._handle_server_request(sreq) + continue + + note = self._client.take_notification( + timeout=notification_poll_timeout + ) + if note is None: + continue + + method = note.get("method", "") + if self._on_event is not None: + try: + self._on_event(note) + except Exception: # pragma: no cover - display callback + logger.debug("on_event callback raised", exc_info=True) + + _apply_token_usage_notification(result, note) + _apply_compaction_notification(result, note) + self._track_pending_file_change(note) + + projection = projector.project(note) + if projection.messages: + result.projected_messages.extend(projection.messages) + if projection.is_tool_iteration: + result.tool_iterations += 1 + if projection.final_text is not None: + result.final_text = projection.final_text + if _has_turn_aborted_marker(projection.final_text): + turn_complete = True + result.interrupted = True + result.error = ( + result.error or "codex reported turn_aborted" + ) + + if method == "turn/started": + turn_obj = (note.get("params") or {}).get("turn") or {} + result.turn_id = turn_obj.get("id") or result.turn_id + elif method == "turn/completed": + turn_complete = True + turn_obj = (note.get("params") or {}).get("turn") or {} + result.turn_id = turn_obj.get("id") or result.turn_id + turn_status = turn_obj.get("status") + if turn_status and turn_status not in {"completed", "interrupted"}: + err_obj = turn_obj.get("error") + if err_obj: + err_msg = _format_responses_error(err_obj, str(turn_status)) + stderr_blob = "\n".join( + self._client.stderr_tail(40) + ) + hint = _classify_oauth_failure(err_msg, stderr_blob) + if hint is not None: + result.error = hint + result.should_retire = True + else: + result.error = self._format_error_with_stderr( + f"compact turn ended status={turn_status}", + err_msg, + ) + + if not turn_complete and not result.interrupted: + self._issue_interrupt(result.turn_id) + result.interrupted = True + if not result.error: + result.error = self._format_error_with_stderr( + f"compact turn timed out after {turn_timeout}s" + ) + result.should_retire = True + + return result + # ---------- internals ---------- def _issue_interrupt(self, turn_id: Optional[str]) -> None: @@ -845,6 +996,38 @@ def _apply_token_usage_notification(result: TurnResult, note: dict) -> None: result.model_context_window = window +def _apply_compaction_notification(result: TurnResult, note: dict) -> None: + """Capture Codex-native context compaction boundaries. + + Recent app-server builds expose compaction as a ContextCompaction item. + Older builds also emit the deprecated thread/compacted notification. Both + mean the underlying Codex thread history has been compacted. + """ + if not isinstance(note, dict): + return + method = note.get("method") or "" + params = note.get("params") or {} + if not isinstance(params, dict): + return + + if method == "thread/compacted": + result.compacted = True + result.thread_id = params.get("threadId") or result.thread_id + result.turn_id = params.get("turnId") or result.turn_id + return + + if method not in {"item/started", "item/completed"}: + return + + item = params.get("item") or {} + if not isinstance(item, dict) or item.get("type") != "contextCompaction": + return + + result.compacted = True + result.thread_id = params.get("threadId") or result.thread_id + result.turn_id = params.get("turnId") or result.turn_id + + def _approval_choice_to_codex_decision(choice: str) -> str: """Map Hermes approval choices onto codex's CommandExecutionApprovalDecision / FileChangeApprovalDecision wire values. diff --git a/agent/turn_context.py b/agent/turn_context.py index 89b00819c2cd..4fd6ddff2c3e 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -369,6 +369,20 @@ def build_turn_context( lambda _tokens: False, ) _preflight_deferred = _defer_preflight(_preflight_tokens) + # Codex app-server threads are compacted by the codex agent itself; + # Hermes only initiates compaction in "hermes" mode (#36801). + _codex_native_auto = ( + getattr(agent, "api_mode", None) == "codex_app_server" + and str( + getattr( + agent, + "codex_app_server_auto_compaction", + "native", + ) + or "native" + ).lower() + in {"native", "off"} + ) if not _preflight_deferred: _last = _compressor.last_prompt_tokens @@ -397,6 +411,12 @@ def build_turn_context( int(_compression_cooldown.get("remaining_seconds", 0.0)), agent.session_id or "none", ) + elif _codex_native_auto: + logger.info( + "Skipping Hermes preflight compression for codex app-server " + "(mode=%s); Hermes will not start thread compaction here.", + getattr(agent, "codex_app_server_auto_compaction", "native"), + ) elif _compressor.should_compress(_preflight_tokens): logger.info( "Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)", diff --git a/cli-config.yaml.example b/cli-config.yaml.example index f058705cfe21..8de33b807353 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -410,6 +410,21 @@ compression: # Trigger compression at this % of model's context limit (default: 0.50 = 50%) # Lower values = more aggressive compression, higher values = compress later threshold: 0.50 + + # Existing Codex gpt-5.5 behavior: raise Hermes' compaction trigger to 85% + # for the ChatGPT Codex OAuth route. Set false to opt back down to threshold. + codex_gpt55_autoraise: true + + # Codex-native compaction paths are opt-in (default: false) to preserve + # Hermes' existing auxiliary summarizer behavior for existing installs. + # When true, Codex OAuth uses Responses API compact and Codex app-server + # compaction uses the app-server thread compact API. + codex_native_compaction: false + + # Codex OAuth / Responses API compaction trigger (default: 0.85 = 85%). + # Used only when codex_native_compaction is true. The recent tail still uses + # protect_last_n below. + codex_responses_threshold: 0.85 # Fraction of the threshold to preserve as recent tail (default: 0.20 = 20%) # e.g. 20% of 50% threshold = 10% of total context kept as recent messages. @@ -422,6 +437,13 @@ compression: # compression of older turns. protect_last_n: 20 + # Codex app-server auto-compaction mode: + # Used only when codex_native_compaction is true. + # native = let Codex decide when to compact its own thread (default) + # hermes = let Hermes threshold trigger Codex thread/compact/start + # off = Hermes will not auto-trigger compaction; Codex may still compact natively + codex_app_server_auto: native + # Number of non-system messages to protect at the head of the transcript, in # ADDITION to the system prompt (which is always implicitly protected). # Head messages are NEVER summarized — they survive every compression diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 81d66e99eba0..7f2945de5f80 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1377,6 +1377,14 @@ DEFAULT_CONFIG = { # autoraise banner. Set False to keep the # 85% threshold autoraise but suppress the # user-facing notice in CLI/gateway output. + "codex_app_server_auto": "native", # Codex app-server (codex CLI runtime) thread + # compaction mode. The codex agent owns the real + # thread context, so Hermes' summarizer cannot + # shrink it (#36801). native = codex decides when + # to compact its own thread (default); hermes = + # Hermes' compression threshold triggers + # thread/compact/start; off = never auto-trigger + # (codex may still compact natively). "in_place": True, # When True, compaction rewrites the message # list and rebuilds the system prompt WITHOUT # rotating the session id — the conversation diff --git a/scripts/release.py b/scripts/release.py index 32f7c577a27c..b7895aa051be 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -163,6 +163,7 @@ AUTHOR_MAP = { "65363919+coygeek@users.noreply.github.com": "coygeek", # PR #37951 salvage (fail closed when provider env blocklist import fails; #37950) "5261694+djstunami@users.noreply.github.com": "djstunami", # PR #5316 salvage / co-author (suppress transient check_fn flakes so subagents keep file/terminal tools; #21658 / #5304) "jmmaloney4@gmail.com": "jmmaloney4", # PR #25206 salvage (re-select credential pool on primary runtime restore; #25205) + "hmirin@users.noreply.github.com": "hmirin", "dale@dalenguyen.me": "dalenguyen", # PR #53678 salvage (strip VIRTUAL_ENV/CONDA_PREFIX from terminal subprocess env; #23473) "liruixinch@outlook.com": "HexLab98", # PR #53863 salvage (env-only proxy policy for auxiliary OpenAI clients on macOS; #53702) "blaryx@gmail.com": "Blaryxoff", # PR #32602 salvage (deep-merge PUT /api/config to preserve unrelated sections; #13396) diff --git a/tests/agent/transports/test_codex_app_server_session.py b/tests/agent/transports/test_codex_app_server_session.py index 0322fb087217..adc3470131e3 100644 --- a/tests/agent/transports/test_codex_app_server_session.py +++ b/tests/agent/transports/test_codex_app_server_session.py @@ -445,6 +445,107 @@ class TestRunTurn: r = s.run_turn("x", turn_timeout=1.0) assert r.error and "model error" in r.error + def test_run_turn_records_native_compaction_item(self): + client = FakeClient() + client.queue_notification( + "item/completed", + threadId="thread-fake-001", + turnId="turn-fake-001", + item={"type": "contextCompaction", "id": "compact-item-1"}, + ) + client.queue_notification( + "turn/completed", threadId="thread-fake-001", + turn={"id": "turn-fake-001", "status": "completed", "error": None}, + ) + + r = make_session(client).run_turn("x", turn_timeout=1.0) + + assert r.compacted is True + assert r.thread_id == "thread-fake-001" + assert r.turn_id == "turn-fake-001" + + def test_run_turn_records_deprecated_thread_compacted_notification(self): + client = FakeClient() + client.queue_notification( + "thread/compacted", + threadId="thread-fake-001", + turnId="turn-fake-001", + ) + client.queue_notification( + "turn/completed", threadId="thread-fake-001", + turn={"id": "turn-fake-001", "status": "completed", "error": None}, + ) + + r = make_session(client).run_turn("x", turn_timeout=1.0) + + assert r.compacted is True + + +class TestCompactThread: + def test_compact_thread_sends_rpc_and_waits_for_completion(self): + client = FakeClient() + client.queue_notification( + "turn/started", + threadId="thread-fake-001", + turn={"id": "compact-turn-1"}, + ) + client.queue_notification( + "item/completed", + threadId="thread-fake-001", + turnId="compact-turn-1", + item={"type": "contextCompaction", "id": "compact-item-1"}, + ) + client.queue_notification( + "item/completed", + threadId="thread-fake-001", + turnId="compact-turn-1", + item={"type": "agentMessage", "id": "m1", "text": "compacted"}, + ) + client.queue_notification( + "thread/tokenUsage/updated", + threadId="thread-fake-001", + turnId="compact-turn-1", + tokenUsage={ + "last": {"inputTokens": 10, "outputTokens": 2, "totalTokens": 12}, + "total": {"inputTokens": 100, "outputTokens": 20, "totalTokens": 120}, + "modelContextWindow": 200000, + }, + ) + client.queue_notification( + "turn/completed", + threadId="thread-fake-001", + turn={"id": "compact-turn-1", "status": "completed", "error": None}, + ) + + r = make_session(client).compact_thread(turn_timeout=2.0) + + assert ("thread/compact/start", {"threadId": "thread-fake-001"}) in client.requests + assert r.error is None + assert r.thread_id == "thread-fake-001" + assert r.turn_id == "compact-turn-1" + assert r.compacted is True + assert r.final_text == "compacted" + assert r.token_usage_last["totalTokens"] == 12 + assert r.model_context_window == 200000 + + def test_compact_thread_failure_returns_error(self): + client = FakeClient() + from agent.transports.codex_app_server import CodexAppServerError + + def boom(method, params): + if method == "thread/compact/start": + raise CodexAppServerError(code=-32603, message="compact unavailable") + if method == "thread/start": + return {"thread": {"id": "thread-fake-001"}} + return {} + + client._request_handler = boom + r = make_session(client).compact_thread(turn_timeout=2.0) + + assert r.error is not None + assert "compact unavailable" in r.error + assert r.should_retire is False + # ---- approval bridge ---- diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index 73a4941db3f4..708b2d3ac71c 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -228,16 +228,24 @@ class TestExtractCacheBustingConfig: "compression": { "enabled": False, "threshold": 0.6, + "codex_gpt55_autoraise": False, + "codex_native_compaction": True, + "codex_responses_threshold": 0.85, "target_ratio": 0.3, "protect_last_n": 25, + "codex_app_server_auto": "hermes", "some_other_key": "ignored", } } ) assert out["compression.enabled"] is False assert out["compression.threshold"] == 0.6 + assert out["compression.codex_gpt55_autoraise"] is False + assert out["compression.codex_native_compaction"] is True + assert out["compression.codex_responses_threshold"] == 0.85 assert out["compression.target_ratio"] == 0.3 assert out["compression.protect_last_n"] == 25 + assert out["compression.codex_app_server_auto"] == "hermes" def test_missing_keys_yield_none(self): """Absent config keys must produce None values (still contribute to signature).""" @@ -2116,4 +2124,3 @@ class TestCrossProcessInvalidationDefersCleanup: assert release_calls == [old_agent] runner._cleanup_agent_resources.assert_not_called() - diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index b777bf393cec..b580ca14471c 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -1780,3 +1780,54 @@ class TestConfigNormalizationDoesNotOverwriteUserValues: def test_explicit_config_paths_ignore_empty_sections(self): assert _explicit_config_paths({"memory": {}, "display": {}}) == set() + + +class TestCodexNativeCompactionConfig: + """Codex-native compaction stays opt-in without mutating existing configs.""" + + def _write(self, tmp_path, body): + (tmp_path / "config.yaml").write_text(body, encoding="utf-8") + + def test_default_config_keeps_codex_native_compaction_opt_in(self): + assert DEFAULT_CONFIG["compression"]["codex_native_compaction"] is False + assert DEFAULT_CONFIG["compression"]["codex_gpt55_autoraise"] is True + + def test_migration_does_not_write_codex_native_compaction_default(self, tmp_path): + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + self._write( + tmp_path, + "_config_version: 31\n" + "compression:\n" + " threshold: 0.5\n" + " codex_gpt55_autoraise: false\n", + ) + + result = migrate_config(interactive=False, quiet=True) + + raw = yaml.safe_load((tmp_path / "config.yaml").read_text()) + assert raw["compression"]["codex_gpt55_autoraise"] is False + assert "codex_native_compaction" not in raw["compression"] + assert raw["compression"]["threshold"] == 0.5 + assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"] + assert ( + "compression.codex_native_compaction=false" + not in result["config_added"] + ) + + def test_preserves_existing_codex_native_compaction_value(self, tmp_path): + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + self._write( + tmp_path, + "_config_version: 31\n" + "compression:\n" + " codex_native_compaction: true\n", + ) + + result = migrate_config(interactive=False, quiet=True) + + raw = yaml.safe_load((tmp_path / "config.yaml").read_text()) + assert raw["compression"]["codex_native_compaction"] is True + assert ( + "compression.codex_native_compaction=false" + not in result["config_added"] + ) diff --git a/tests/run_agent/test_codex_app_server_compaction.py b/tests/run_agent/test_codex_app_server_compaction.py new file mode 100644 index 000000000000..8f9cea51f812 --- /dev/null +++ b/tests/run_agent/test_codex_app_server_compaction.py @@ -0,0 +1,196 @@ +from types import SimpleNamespace + +from agent.codex_runtime import _record_codex_app_server_compaction +from agent.conversation_compression import COMPACTION_STATUS, compress_context +from agent.transports.codex_app_server_session import TurnResult + + +class FakeCodexSession: + def __init__(self, result): + self.result = result + self.calls = 0 + self.closed = False + + def compact_thread(self): + self.calls += 1 + return self.result + + def close(self): + self.closed = True + + +class DummyAgent: + def __init__( + self, + result, + *, + auto_compaction="native", + codex_native_compaction=True, + ): + self.api_mode = "codex_app_server" + self.codex_native_compaction_enabled = codex_native_compaction + self.codex_app_server_auto_compaction = auto_compaction + self.session_id = "hermes-session-1" + self.platform = "cli" + self._cached_system_prompt = "cached prompt" + self._codex_session = FakeCodexSession(result) + self.context_compressor = SimpleNamespace( + compression_count=0, + last_compression_rough_tokens=0, + last_prompt_tokens=123, + last_completion_tokens=45, + awaiting_real_usage_after_compression=False, + ) + self.statuses = [] + self.warnings = [] + self.events = [] + self.built_prompts = [] + + def _emit_status(self, message): + self.statuses.append(message) + + def _emit_warning(self, message): + self.warnings.append(message) + + def _build_system_prompt(self, system_message): + self.built_prompts.append(system_message) + return "built prompt" + + def event_callback(self, name, payload): + self.events.append((name, payload)) + + +def test_codex_app_server_native_auto_mode_leaves_thread_compaction_to_codex(): + agent = DummyAgent( + TurnResult(thread_id="thread-1", turn_id="compact-turn-1") + ) + messages = [{"role": "user", "content": "hi"}] + + returned, prompt = compress_context( + agent, + messages, + "system", + approx_tokens=100000, + task_id="test", + ) + + assert returned is messages + assert prompt == "cached prompt" + assert agent._codex_session.calls == 0 + assert agent.context_compressor.compression_count == 0 + assert agent.events == [] + + +def test_codex_app_server_manual_compression_routes_to_codex_thread(): + agent = DummyAgent( + TurnResult(thread_id="thread-1", turn_id="compact-turn-1") + ) + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ] + + returned, prompt = compress_context( + agent, + messages, + "system", + approx_tokens=100000, + task_id="test", + force=True, + ) + + assert returned is messages + assert prompt == "cached prompt" + assert agent._codex_session.calls == 1 + assert agent.context_compressor.compression_count == 1 + assert agent.context_compressor.last_compression_rough_tokens == 100000 + assert agent.context_compressor.last_prompt_tokens == -1 + assert agent.context_compressor.last_completion_tokens == 0 + assert agent.context_compressor.awaiting_real_usage_after_compression is True + assert agent.events == [ + ( + "session:compress", + { + "platform": "cli", + "session_id": "hermes-session-1", + "old_session_id": "", + "in_place": False, + "compression_count": 1, + "runtime": "codex_app_server", + "thread_id": "thread-1", + "turn_id": "compact-turn-1", + }, + ) + ] + + +def test_codex_app_server_hermes_mode_auto_compression_routes_to_codex_thread(): + agent = DummyAgent( + TurnResult(thread_id="thread-1", turn_id="compact-turn-1"), + auto_compaction="hermes", + ) + messages = [{"role": "user", "content": "hi"}] + + returned, prompt = compress_context( + agent, + messages, + "system", + approx_tokens=100000, + ) + + assert returned is messages + assert prompt == "cached prompt" + assert agent._codex_session.calls == 1 + assert agent.context_compressor.compression_count == 1 + + +def test_codex_app_server_compression_failure_preserves_bookkeeping(): + agent = DummyAgent(TurnResult(error="compact failed")) + messages = [{"role": "user", "content": "hi"}] + + returned, prompt = compress_context( + agent, + messages, + "system", + approx_tokens=100000, + force=True, + ) + + assert returned is messages + assert prompt == "cached prompt" + assert agent._codex_session.calls == 1 + assert agent.context_compressor.compression_count == 0 + assert agent.context_compressor.last_prompt_tokens == 123 + assert agent.warnings + + +def test_codex_app_server_native_compaction_notice_emits_status_and_event(): + agent = DummyAgent( + TurnResult(thread_id="thread-1", turn_id="normal-turn-1") + ) + turn = TurnResult( + thread_id="thread-1", + turn_id="normal-turn-1", + compacted=True, + ) + + recorded = _record_codex_app_server_compaction(agent, turn) + + assert recorded is True + assert agent.context_compressor.compression_count == 1 + assert agent.statuses == [COMPACTION_STATUS] + assert agent.events == [ + ( + "session:compress", + { + "platform": "cli", + "session_id": "hermes-session-1", + "old_session_id": "", + "in_place": False, + "compression_count": 1, + "runtime": "codex_app_server", + "thread_id": "thread-1", + "turn_id": "normal-turn-1", + }, + ) + ] diff --git a/tests/run_agent/test_codex_app_server_integration.py b/tests/run_agent/test_codex_app_server_integration.py index f9d2dcf652d0..df16807ca84e 100644 --- a/tests/run_agent/test_codex_app_server_integration.py +++ b/tests/run_agent/test_codex_app_server_integration.py @@ -49,7 +49,7 @@ def fake_session(monkeypatch): ) -def _make_codex_agent(): +def _make_codex_agent(**kwargs): """Construct an AIAgent in codex_app_server mode without contacting any real provider. We pass api_mode explicitly so the constructor takes the fast path for direct credentials.""" @@ -61,6 +61,7 @@ def _make_codex_agent(): quiet_mode=True, skip_context_files=True, skip_memory=True, + **kwargs, ) @@ -134,6 +135,46 @@ class TestRunConversationCodexPath: assert agent.context_compressor.last_total_tokens == 130 assert agent.context_compressor.context_length == 200000 + def test_native_codex_compaction_updates_bookkeeping(self, monkeypatch): + def fake_run_turn(self, user_input: str, **kwargs): + return TurnResult( + final_text="done", + projected_messages=[{"role": "assistant", "content": "done"}], + turn_id="turn-compact-1", + thread_id="thread-compact-1", + compacted=True, + ) + + monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn) + monkeypatch.setattr( + CodexAppServerSession, "ensure_started", lambda self: "thread-compact-1" + ) + events = [] + agent = _make_codex_agent(event_callback=lambda name, payload: events.append((name, payload))) + + with patch.object(agent, "_spawn_background_review", return_value=None): + result = agent.run_conversation("hello") + + assert result["completed"] is True + assert agent.context_compressor.compression_count == 1 + assert agent.context_compressor.last_prompt_tokens == -1 + assert agent.context_compressor.awaiting_real_usage_after_compression is True + assert events == [ + ( + "session:compress", + { + "platform": "", + "session_id": agent.session_id, + "old_session_id": "", + "in_place": False, + "compression_count": 1, + "runtime": "codex_app_server", + "thread_id": "thread-compact-1", + "turn_id": "turn-compact-1", + }, + ) + ] + def test_projected_messages_are_spliced(self, fake_session): agent = _make_codex_agent() with patch.object(agent, "_spawn_background_review", return_value=None): diff --git a/website/docs/developer-guide/context-compression-and-caching.md b/website/docs/developer-guide/context-compression-and-caching.md index d9f4f6aac054..aa817190c174 100644 --- a/website/docs/developer-guide/context-compression-and-caching.md +++ b/website/docs/developer-guide/context-compression-and-caching.md @@ -86,6 +86,7 @@ compression: protect_last_n: 20 # Minimum protected tail messages (default: 20) codex_gpt55_autoraise: true # gpt-5.5 on Codex OAuth: raise trigger to 85% (default: true) codex_gpt55_autoraise_notice: true # Show the one-time autoraise notice (default: true) + codex_app_server_auto: native # native|hermes|off for Codex app-server thread compaction # Summarization model/provider configured under auxiliary: auxiliary: @@ -105,6 +106,7 @@ auxiliary: | `protect_first_n` | `3` | (hardcoded) | System prompt + first exchange always preserved | | `codex_gpt55_autoraise` | `true` | bool | Raise the trigger to 85% for gpt-5.5 on the ChatGPT Codex OAuth route (see below). Set `false` to keep the global `threshold` | | `codex_gpt55_autoraise_notice` | `true` | bool | Show the one-time Codex gpt-5.5 autoraise notice. Set `false` to keep the 85% autoraise but suppress the banner | +| `codex_app_server_auto` | `native` | `native`, `hermes`, `off` | Thread-compaction mode for Codex app-server sessions (see below) | ### Codex gpt-5.5 threshold autoraise @@ -131,6 +133,28 @@ To keep the 85% autoraise but hide only the one-time notice: hermes config set compression.codex_gpt55_autoraise_notice false ``` +### Codex app-server thread compaction + +Codex app-server sessions (`api_mode: codex_app_server` — the codex CLI/agent +runtime) are different from every other route: the codex agent owns the backing +thread context, so Hermes' auxiliary summarizer cannot shrink it — rewriting the +local transcript mirror leaves the real thread growing unbounded until a hard +context reset. For this runtime, compaction goes through the app-server's own +mechanism instead: + +- Manual compaction (`/compress`) asks the app-server to compact the thread + (`thread/compact/start`) and waits for the compaction turn to complete. +- Automatic compaction is controlled by `compression.codex_app_server_auto`: + the default `native` lets the app-server decide when to compact and Hermes + records the resulting compaction events (compression counters, session + events). Set `hermes` to let Hermes' compression threshold initiate + app-server compaction, or `off` to disable Hermes-initiated automatic + compaction entirely (codex may still compact natively). + +Hermes' local transcript is never rewritten on this runtime — state.db records +the compaction boundary while the visible transcript stays intact. All other +routes (including Codex OAuth chat sessions) keep Hermes' summary compressor. + ### Computed Values (for a 200K context model at defaults) ``` From 87b65e24a799ded350c31afcdb7077cf45bf5c13 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:11:23 -0700 Subject: [PATCH 080/610] refactor(compression): scope Codex-native compaction to the app-server runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the Responses-API native compaction path and its opt-in umbrella flag from the salvaged feature. On the Codex OAuth chat route Hermes owns the message list and the summary compressor works (and stays provider-portable — encrypted compaction items would lock the session history to chatgpt.com and break /model switches and provider fallback). On the app-server runtime (codex CLI/agent) the codex agent owns the real thread context, so thread/compact/start is the only mechanism that can actually shrink it (#36801) — that path is now the default behavior for codex_app_server sessions, controlled by compression.codex_app_server_auto (native|hermes|off), no umbrella flag. Removed: responses.compact() call path, codex_compaction_items replay/ persistence plumbing, codex_native_compaction + codex_responses_threshold config keys, desktop settings fields, and their tests. Kept: everything app-server (compact_thread(), compaction notifications, bookkeeping, docs, tests) plus cache-busting keys for the surviving knobs. --- cli-config.yaml.example | 16 ++------ gateway/run.py | 2 + tests/gateway/test_agent_cache.py | 4 -- tests/hermes_cli/test_config.py | 40 ++++--------------- .../test_codex_app_server_compaction.py | 2 - 5 files changed, 13 insertions(+), 51 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 8de33b807353..5e4bc2331771 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -415,17 +415,6 @@ compression: # for the ChatGPT Codex OAuth route. Set false to opt back down to threshold. codex_gpt55_autoraise: true - # Codex-native compaction paths are opt-in (default: false) to preserve - # Hermes' existing auxiliary summarizer behavior for existing installs. - # When true, Codex OAuth uses Responses API compact and Codex app-server - # compaction uses the app-server thread compact API. - codex_native_compaction: false - - # Codex OAuth / Responses API compaction trigger (default: 0.85 = 85%). - # Used only when codex_native_compaction is true. The recent tail still uses - # protect_last_n below. - codex_responses_threshold: 0.85 - # Fraction of the threshold to preserve as recent tail (default: 0.20 = 20%) # e.g. 20% of 50% threshold = 10% of total context kept as recent messages. # Summary output is separately capped at 12K tokens (Gemini output limit). @@ -437,8 +426,9 @@ compression: # compression of older turns. protect_last_n: 20 - # Codex app-server auto-compaction mode: - # Used only when codex_native_compaction is true. + # Codex app-server (codex CLI runtime) thread-compaction mode. The codex + # agent owns the real thread context on this runtime, so Hermes' summarizer + # cannot shrink it — compaction goes through the app server instead. # native = let Codex decide when to compact its own thread (default) # hermes = let Hermes threshold trigger Codex thread/compact/start # off = Hermes will not auto-trigger compaction; Codex may still compact natively diff --git a/gateway/run.py b/gateway/run.py index d47b11ebb770..d1e620613db8 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -15321,6 +15321,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ("model", "max_tokens"), ("compression", "enabled"), ("compression", "threshold"), + ("compression", "codex_gpt55_autoraise"), + ("compression", "codex_app_server_auto"), ("compression", "target_ratio"), ("compression", "protect_last_n"), ("agent", "disabled_toolsets"), diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index 708b2d3ac71c..81264fa37c9d 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -229,8 +229,6 @@ class TestExtractCacheBustingConfig: "enabled": False, "threshold": 0.6, "codex_gpt55_autoraise": False, - "codex_native_compaction": True, - "codex_responses_threshold": 0.85, "target_ratio": 0.3, "protect_last_n": 25, "codex_app_server_auto": "hermes", @@ -241,8 +239,6 @@ class TestExtractCacheBustingConfig: assert out["compression.enabled"] is False assert out["compression.threshold"] == 0.6 assert out["compression.codex_gpt55_autoraise"] is False - assert out["compression.codex_native_compaction"] is True - assert out["compression.codex_responses_threshold"] == 0.85 assert out["compression.target_ratio"] == 0.3 assert out["compression.protect_last_n"] == 25 assert out["compression.codex_app_server_auto"] == "hermes" diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index b580ca14471c..06bc395214f7 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -1782,52 +1782,28 @@ class TestConfigNormalizationDoesNotOverwriteUserValues: assert _explicit_config_paths({"memory": {}, "display": {}}) == set() -class TestCodexNativeCompactionConfig: - """Codex-native compaction stays opt-in without mutating existing configs.""" +class TestCodexAppServerAutoConfig: + """codex_app_server_auto ships a default and survives migration untouched.""" def _write(self, tmp_path, body): (tmp_path / "config.yaml").write_text(body, encoding="utf-8") - def test_default_config_keeps_codex_native_compaction_opt_in(self): - assert DEFAULT_CONFIG["compression"]["codex_native_compaction"] is False + def test_default_config_has_native_mode(self): + assert DEFAULT_CONFIG["compression"]["codex_app_server_auto"] == "native" assert DEFAULT_CONFIG["compression"]["codex_gpt55_autoraise"] is True - def test_migration_does_not_write_codex_native_compaction_default(self, tmp_path): + def test_preserves_existing_codex_app_server_auto_value(self, tmp_path): with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): self._write( tmp_path, "_config_version: 31\n" "compression:\n" - " threshold: 0.5\n" - " codex_gpt55_autoraise: false\n", + " codex_app_server_auto: hermes\n", ) - result = migrate_config(interactive=False, quiet=True) + migrate_config(interactive=False, quiet=True) raw = yaml.safe_load((tmp_path / "config.yaml").read_text()) - assert raw["compression"]["codex_gpt55_autoraise"] is False - assert "codex_native_compaction" not in raw["compression"] - assert raw["compression"]["threshold"] == 0.5 - assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"] - assert ( - "compression.codex_native_compaction=false" - not in result["config_added"] - ) + assert raw["compression"]["codex_app_server_auto"] == "hermes" - def test_preserves_existing_codex_native_compaction_value(self, tmp_path): - with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): - self._write( - tmp_path, - "_config_version: 31\n" - "compression:\n" - " codex_native_compaction: true\n", - ) - result = migrate_config(interactive=False, quiet=True) - - raw = yaml.safe_load((tmp_path / "config.yaml").read_text()) - assert raw["compression"]["codex_native_compaction"] is True - assert ( - "compression.codex_native_compaction=false" - not in result["config_added"] - ) diff --git a/tests/run_agent/test_codex_app_server_compaction.py b/tests/run_agent/test_codex_app_server_compaction.py index 8f9cea51f812..4bd5e8431dd2 100644 --- a/tests/run_agent/test_codex_app_server_compaction.py +++ b/tests/run_agent/test_codex_app_server_compaction.py @@ -25,10 +25,8 @@ class DummyAgent: result, *, auto_compaction="native", - codex_native_compaction=True, ): self.api_mode = "codex_app_server" - self.codex_native_compaction_enabled = codex_native_compaction self.codex_app_server_auto_compaction = auto_compaction self.session_id = "hermes-session-1" self.platform = "cli" From b8ce583e05f90bf2d250cd0c7014248658aece86 Mon Sep 17 00:00:00 2001 From: ooiuuii Date: Mon, 29 Jun 2026 16:45:52 +0800 Subject: [PATCH 081/610] fix(discord): bound REST response reads Refs NousResearch/hermes-agent#54745 --- tests/tools/test_discord_tool.py | 35 +++++++++++++++++++++++++++ tools/discord_tool.py | 41 ++++++++++++++++++++++++-------- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/tests/tools/test_discord_tool.py b/tests/tools/test_discord_tool.py index 4bc960f7440e..2a7d2793aaaf 100644 --- a/tests/tools/test_discord_tool.py +++ b/tests/tools/test_discord_tool.py @@ -142,6 +142,41 @@ class TestDiscordRequest: assert exc_info.value.status == 403 assert "Missing Access" in exc_info.value.body + @patch("tools.discord_tool.urllib.request.urlopen") + def test_response_body_size_limit(self, mock_urlopen_fn, monkeypatch): + monkeypatch.setattr("tools.discord_tool._DISCORD_RESPONSE_BODY_MAX_BYTES", 8) + mock_resp = MagicMock() + mock_resp.status = 200 + mock_resp.read.return_value = b"x" * 9 + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen_fn.return_value = mock_resp + + with pytest.raises(DiscordAPIError) as exc_info: + _discord_request("GET", "/test", "tok") + + assert exc_info.value.status == 502 + assert "response body exceeded 8 bytes" in exc_info.value.body + mock_resp.read.assert_called_once_with(9) + + @patch("tools.discord_tool.urllib.request.urlopen") + def test_http_error_body_size_limit(self, mock_urlopen_fn, monkeypatch): + monkeypatch.setattr("tools.discord_tool._DISCORD_ERROR_BODY_MAX_BYTES", 8) + http_error = urllib.error.HTTPError( + url="https://discord.com/api/v10/test", + code=403, + msg="Forbidden", + hdrs={}, + fp=BytesIO(b"x" * 9), + ) + mock_urlopen_fn.side_effect = http_error + + with pytest.raises(DiscordAPIError) as exc_info: + _discord_request("GET", "/test", "tok") + + assert exc_info.value.status == 403 + assert "error body exceeded 8 bytes" in exc_info.value.body + # --------------------------------------------------------------------------- # Main handler: validation diff --git a/tools/discord_tool.py b/tools/discord_tool.py index 9dd397ccba4c..34d60d86dbef 100644 --- a/tools/discord_tool.py +++ b/tools/discord_tool.py @@ -42,6 +42,8 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) DISCORD_API_BASE = "https://discord.com/api/v10" +_DISCORD_RESPONSE_BODY_MAX_BYTES = 4 * 1024 * 1024 +_DISCORD_ERROR_BODY_MAX_BYTES = 64 * 1024 # Application flag bits (from GET /applications/@me → "flags"). # Source: https://discord.com/developers/docs/resources/application#application-object-application-flags @@ -54,6 +56,21 @@ _FLAG_GATEWAY_MESSAGE_CONTENT_LIMITED = 1 << 19 # Helpers # --------------------------------------------------------------------------- +class DiscordAPIError(Exception): + """Raised when a Discord API call fails.""" + def __init__(self, status: int, body: str): + self.status = status + self.body = body + super().__init__(f"Discord API error {status}: {body}") + + +def _read_limited_response_body(source: Any, limit: int, *, label: str) -> bytes: + body = source.read(limit + 1) + if len(body) > limit: + raise DiscordAPIError(502, f"Discord API {label} exceeded {limit} bytes.") + return body + + def _get_bot_token() -> Optional[str]: """Resolve the Discord bot token from environment.""" return os.getenv("DISCORD_BOT_TOKEN", "").strip() or None @@ -91,24 +108,28 @@ def _discord_request( with urllib.request.urlopen(req, timeout=timeout) as resp: if resp.status == 204: return None - return json.loads(resp.read().decode("utf-8")) + response_body = _read_limited_response_body( + resp, + _DISCORD_RESPONSE_BODY_MAX_BYTES, + label="response body", + ) + return json.loads(response_body.decode("utf-8")) except urllib.error.HTTPError as e: error_body = "" try: - error_body = e.read().decode("utf-8", errors="replace") + raw_error_body = _read_limited_response_body( + e, + _DISCORD_ERROR_BODY_MAX_BYTES, + label="error body", + ) + error_body = raw_error_body.decode("utf-8", errors="replace") + except DiscordAPIError as too_large: + error_body = too_large.body except Exception: pass raise DiscordAPIError(e.code, error_body) from e -class DiscordAPIError(Exception): - """Raised when a Discord API call fails.""" - def __init__(self, status: int, body: str): - self.status = status - self.body = body - super().__init__(f"Discord API error {status}: {body}") - - # --------------------------------------------------------------------------- # Channel type mapping # --------------------------------------------------------------------------- From 87be36c240ecc14c4b47584b401fc236acba549f Mon Sep 17 00:00:00 2001 From: ooiuuii Date: Mon, 29 Jun 2026 23:08:03 +0800 Subject: [PATCH 082/610] fix(discord): bound component labels by UTF-16 units --- plugins/platforms/discord/adapter.py | 40 ++++++++++---- tests/gateway/test_discord_clarify_buttons.py | 14 +++++ tests/gateway/test_discord_model_picker.py | 53 +++++++++++++++++++ 3 files changed, 98 insertions(+), 9 deletions(-) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 92955e631a03..380ef1315110 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -52,6 +52,9 @@ _DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS = 30.0 # every slash command — not just the overflow ones. We keep the desired set # at or below this limit at registration time. _DISCORD_MAX_APP_COMMANDS = 100 +_DISCORD_SELECT_FIELD_LIMIT = 100 +_DISCORD_BUTTON_LABEL_LIMIT = 80 +_DISCORD_ELLIPSIS = "\u2026" _DISCORD_NONCONVERSATIONAL_METADATA_KEYS = frozenset({ "non_conversational", "non_conversational_history", @@ -117,11 +120,18 @@ from gateway.platforms.base import ( cache_document_from_bytes, SUPPORTED_DOCUMENT_TYPES, _TEXT_INJECT_EXTENSIONS, + _prefix_within_utf16_limit, + utf16_len, validate_inbound_media_size, ) from tools.url_safety import is_safe_url +def _truncate_discord_component_text(text: str, limit: int) -> str: + """Return text within Discord's UTF-16 component field budget.""" + return _prefix_within_utf16_limit(str(text or ""), max(0, limit)) + + async def _wait_for_ready_or_bot_exit( ready_event: asyncio.Event, bot_task: asyncio.Task, @@ -6888,7 +6898,10 @@ def _define_discord_view_classes() -> None: desc = "current" if p.get("is_current") else None options.append( discord.SelectOption( - label=label[:100], + label=_truncate_discord_component_text( + label, + _DISCORD_SELECT_FIELD_LIMIT, + ), value=p["slug"], description=desc, ) @@ -6925,8 +6938,14 @@ def _define_discord_view_classes() -> None: short = model_id.split("/")[-1] if "/" in model_id else model_id options.append( discord.SelectOption( - label=short[:100], - value=model_id[:100], + label=_truncate_discord_component_text( + short, + _DISCORD_SELECT_FIELD_LIMIT, + ), + value=_truncate_discord_component_text( + model_id, + _DISCORD_SELECT_FIELD_LIMIT, + ), ) ) if not options: @@ -7205,15 +7224,18 @@ def _define_discord_view_classes() -> None: # budget (hyphen, comma, period, paren) # 3. Hard cut at the budget limit (last resort) prefix = f"{index + 1}. " - budget = 80 - len(prefix) - if len(choice) <= budget: + budget = _DISCORD_BUTTON_LABEL_LIMIT - utf16_len(prefix) + if utf16_len(choice) <= budget: label_body = choice else: - truncated = choice[: budget - 1].rstrip() + truncated = _prefix_within_utf16_limit( + choice, + max(0, budget - utf16_len(_DISCORD_ELLIPSIS)), + ).rstrip() cut_at = -1 # 1. Last space in the trailing half of the budget. space = truncated.rfind(" ") - if space >= budget // 2: + if space >= len(truncated) // 2: cut_at = space # 2. Soft boundary — only if no word boundary found. # Find the latest soft boundary in the trailing half @@ -7226,11 +7248,11 @@ def _define_discord_view_classes() -> None: (truncated.rfind(s) for s in ("-", ",", ".", ")")), default=-1, ) - if latest_soft >= budget // 2: + if latest_soft >= len(truncated) // 2: cut_at = latest_soft + 1 if cut_at > 0: truncated = truncated[:cut_at] - label_body = truncated.rstrip() + "…" + label_body = truncated.rstrip() + _DISCORD_ELLIPSIS button = discord.ui.Button( label=f"{prefix}{label_body}", style=discord.ButtonStyle.primary, diff --git a/tests/gateway/test_discord_clarify_buttons.py b/tests/gateway/test_discord_clarify_buttons.py index b8b5dc10ed23..a72d225c6186 100644 --- a/tests/gateway/test_discord_clarify_buttons.py +++ b/tests/gateway/test_discord_clarify_buttons.py @@ -30,6 +30,7 @@ from plugins.platforms.discord.adapter import ( # noqa: E402 DiscordAdapter, ) from gateway.config import PlatformConfig # noqa: E402 +from gateway.platforms.base import utf16_len # noqa: E402 # --------------------------------------------------------------------------- @@ -130,6 +131,19 @@ class TestClarifyChoiceViewConstruction: # Final label total <= 80 (Discord cap on button labels) assert len(first_label) <= 80 + def test_truncates_emoji_choice_label_by_utf16_limit(self): + long_choice = "\U0001f600" * 80 + view = ClarifyChoiceView( + choices=[long_choice], + clarify_id="cidEmoji", + allowed_user_ids=set(), + ) + + first_label = view.children[0].label + assert first_label.startswith("1. ") + assert first_label.endswith("\u2026") + assert utf16_len(first_label) <= 80 + def test_truncates_long_choice_label_breaks_on_word_boundary(self): # Long choice with spaces — should cut at the last whole word so the # trailing text stays readable on Discord mobile. diff --git a/tests/gateway/test_discord_model_picker.py b/tests/gateway/test_discord_model_picker.py index 86025f4e4291..369dcb9a977b 100644 --- a/tests/gateway/test_discord_model_picker.py +++ b/tests/gateway/test_discord_model_picker.py @@ -11,6 +11,7 @@ from unittest.mock import AsyncMock import pytest +from gateway.platforms.base import utf16_len from plugins.platforms.discord.adapter import ModelPickerView @@ -82,6 +83,58 @@ async def test_model_picker_clears_controls_before_running_switch_callback(): interaction.edit_original_response.assert_awaited_once() +def test_model_picker_provider_labels_fit_discord_utf16_limit(): + provider_name = "Provider " + ("\U0001f600" * 80) + + view = ModelPickerView( + providers=[ + { + "slug": "emoji", + "name": provider_name, + "models": ["gpt-5-mini"], + "total_models": 1, + "is_current": False, + } + ], + current_model="gpt-5-mini", + current_provider="emoji", + session_key="session-1", + on_model_selected=AsyncMock(return_value="ok"), + allowed_user_ids={"123"}, + ) + + provider_select = view.children[0] + option = provider_select.options[0] + assert utf16_len(option.label) <= 100 + + +def test_model_picker_model_labels_and_values_fit_discord_utf16_limit(): + model_id = "emoji/" + ("\U0001f600" * 80) + + view = ModelPickerView( + providers=[ + { + "slug": "emoji", + "name": "Emoji", + "models": [model_id], + "total_models": 1, + "is_current": False, + } + ], + current_model="gpt-5-mini", + current_provider="emoji", + session_key="session-1", + on_model_selected=AsyncMock(return_value="ok"), + allowed_user_ids={"123"}, + ) + + view._build_model_select("emoji") + model_select = view.children[0] + option = model_select.options[0] + assert utf16_len(option.label) <= 100 + assert utf16_len(option.value) <= 100 + + @pytest.mark.asyncio async def test_expensive_model_requires_confirmation(monkeypatch): events: list[object] = [] From e0bca1cbe2d13af97924dfd8a4c8b0e81ab648b7 Mon Sep 17 00:00:00 2001 From: ooiuuii Date: Tue, 30 Jun 2026 05:44:14 +0800 Subject: [PATCH 083/610] fix(discord): bound standalone response reads --- plugins/platforms/discord/adapter.py | 132 ++++++++++++++++++++++++-- tests/tools/test_send_message_tool.py | 85 +++++++++++++++++ 2 files changed, 208 insertions(+), 9 deletions(-) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 380ef1315110..4f7e6694862a 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -7433,6 +7433,8 @@ if DISCORD_AVAILABLE: # same channel on every send when the directory cache has no entry (e.g. fresh # install, or channel created after the last directory build). _DISCORD_CHANNEL_TYPE_PROBE_CACHE: Dict[str, bool] = {} +_DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES = 1 * 1024 * 1024 +_DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES = 8 * 1024 def _remember_channel_is_forum(chat_id: str, is_forum: bool) -> None: @@ -7469,6 +7471,91 @@ def _standalone_sanitize_error(text) -> str: ) +def _standalone_is_mock_object(value: Any) -> bool: + return value.__class__.__module__.startswith("unittest.mock") + + +def _standalone_close_response(resp: Any) -> None: + close = getattr(resp, "close", None) + if callable(close): + close() + return + release = getattr(resp, "release", None) + if callable(release): + release() + + +async def _standalone_read_response_bytes_limited( + resp: Any, + limit_bytes: int, +) -> Tuple[Optional[bytes], bool]: + content = getattr(resp, "content", None) + if content is None or _standalone_is_mock_object(content): + return None, False + + read = getattr(content, "read", None) + if callable(read): + chunks: list[bytes] = [] + total = 0 + while total <= limit_bytes: + chunk = await read(limit_bytes + 1 - total) + if not chunk: + break + if isinstance(chunk, str): + chunk = chunk.encode("utf-8", "replace") + total += len(chunk) + chunks.append(chunk) + if total > limit_bytes: + _standalone_close_response(resp) + return b"".join(chunks)[:limit_bytes], True + return b"".join(chunks), False + + iter_chunked = getattr(content, "iter_chunked", None) + if callable(iter_chunked): + chunks: list[bytes] = [] + total = 0 + async for chunk in iter_chunked(limit_bytes + 1): + if isinstance(chunk, str): + chunk = chunk.encode("utf-8", "replace") + total += len(chunk) + chunks.append(chunk) + if total > limit_bytes: + _standalone_close_response(resp) + return b"".join(chunks)[:limit_bytes], True + return b"".join(chunks), False + + return None, False + + +def _standalone_response_encoding(resp: Any) -> str: + get_encoding = getattr(resp, "get_encoding", None) + if callable(get_encoding): + try: + return get_encoding() or "utf-8" + except Exception: + return "utf-8" + return "utf-8" + + +async def _standalone_read_text_limited(resp: Any, limit_bytes: int) -> str: + body, _truncated = await _standalone_read_response_bytes_limited(resp, limit_bytes) + if body is None: + return await resp.text() + return body.decode(_standalone_response_encoding(resp), "replace") + + +async def _standalone_read_json_limited(resp: Any, limit_bytes: int) -> dict: + body, truncated = await _standalone_read_response_bytes_limited(resp, limit_bytes) + if body is None: + return await resp.json() + if truncated: + raise ValueError(f"Discord API JSON response exceeds {limit_bytes} bytes") + if not body: + return {} + data = json.loads(body.decode(_standalone_response_encoding(resp), "replace")) + return data if isinstance(data, dict) else {} + + async def _standalone_send( pconfig, chat_id: str, @@ -7544,7 +7631,10 @@ async def _standalone_send( async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15), **_sess_kw) as info_sess: async with info_sess.get(info_url, headers=json_headers, **_req_kw) as info_resp: if info_resp.status == 200: - info = await info_resp.json() + info = await _standalone_read_json_limited( + info_resp, + _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES, + ) is_forum = info.get("type") == 15 _remember_channel_is_forum(chat_id, is_forum) except Exception: @@ -7590,9 +7680,15 @@ async def _standalone_send( ) async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp: if resp.status not in {200, 201}: - body = await resp.text() + body = await _standalone_read_text_limited( + resp, + _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES, + ) return {"error": f"Discord forum thread creation error ({resp.status}): {body}"} - data = await resp.json() + data = await _standalone_read_json_limited( + resp, + _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES, + ) except Exception as e: return {"error": _standalone_sanitize_error(f"Discord forum thread upload failed: {e}")} else: @@ -7608,9 +7704,15 @@ async def _standalone_send( **_req_kw, ) as resp: if resp.status not in {200, 201}: - body = await resp.text() + body = await _standalone_read_text_limited( + resp, + _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES, + ) return {"error": f"Discord forum thread creation error ({resp.status}): {body}"} - data = await resp.json() + data = await _standalone_read_json_limited( + resp, + _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES, + ) thread_id_created = data.get("id") starter_msg_id = (data.get("message") or {}).get("id", thread_id_created) @@ -7632,9 +7734,15 @@ async def _standalone_send( if message.strip() or not media_files: async with session.post(url, headers=json_headers, json={"content": message}, **_req_kw) as resp: if resp.status not in {200, 201}: - body = await resp.text() + body = await _standalone_read_text_limited( + resp, + _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES, + ) return {"error": f"Discord API error ({resp.status}): {body}"} - last_data = await resp.json() + last_data = await _standalone_read_json_limited( + resp, + _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES, + ) # Send each media file as a separate multipart upload for media_path, _is_voice in media_files: @@ -7650,12 +7758,18 @@ async def _standalone_send( form.add_field("files[0]", f, filename=filename) async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp: if resp.status not in {200, 201}: - body = await resp.text() + body = await _standalone_read_text_limited( + resp, + _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES, + ) warning = _standalone_sanitize_error(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}") logger.error(warning) warnings.append(warning) continue - last_data = await resp.json() + last_data = await _standalone_read_json_limited( + resp, + _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES, + ) except Exception as e: warning = _standalone_sanitize_error(f"Failed to send media {media_path}: {e}") logger.error(warning) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 5d28b8b2065a..ed0423322632 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -39,6 +39,8 @@ from tools.send_message_tool import ( # and provide a thin ``_send_discord(token, ...)`` shim that mirrors the # pre-migration signature so the existing test bodies keep working. from plugins.platforms.discord.adapter import ( + _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES, + _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES, _derive_forum_thread_name, _probe_is_forum_cached, _remember_channel_is_forum, @@ -68,6 +70,54 @@ async def _send_discord( ) +class _StreamingAiohttpContent: + def __init__(self, body: bytes): + self._body = body + self.read_sizes = [] + + async def read(self, size=-1): + self.read_sizes.append(size) + if not self._body: + return b"" + if size is None or size < 0: + chunk = self._body + self._body = b"" + return chunk + chunk = self._body[:size] + self._body = self._body[size:] + return chunk + + +class _StreamingAiohttpResponse: + def __init__(self, status: int, body: bytes): + self.status = status + self.content = _StreamingAiohttpContent(body) + self.closed = False + self.json = AsyncMock(side_effect=AssertionError("resp.json() should not be used")) + self.text = AsyncMock(side_effect=AssertionError("resp.text() should not be used")) + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + + def close(self): + self.closed = True + + +class _StreamingAiohttpSession: + def __init__(self, response: _StreamingAiohttpResponse): + self.response = response + self.post = MagicMock(return_value=response) + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + + def _discord_entry(): """Return the live Discord PlatformEntry, importing lazily so plugin discovery is forced exactly once and patches survive across tests.""" @@ -1754,6 +1804,41 @@ class TestSendDiscordThreadId: assert "error" in result assert "403" in result["error"] + def test_success_response_json_read_is_bounded(self): + """Standalone Discord sends parse success JSON through the bounded reader.""" + body = b'{"id":"bounded-json"}' + response = _StreamingAiohttpResponse(200, body) + session = _StreamingAiohttpSession(response) + + with patch("aiohttp.ClientSession", return_value=session): + result = self._run("tok", "111", "hi", thread_id="999") + + assert result["success"] is True + assert result["message_id"] == "bounded-json" + assert response.content.read_sizes[0] == _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES + 1 + response.json.assert_not_awaited() + response.text.assert_not_awaited() + + def test_error_response_text_read_is_bounded(self): + """Oversized Discord API error bodies are capped before formatting.""" + body = b"E" * (_DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES + 1024) + response = _StreamingAiohttpResponse(500, body) + session = _StreamingAiohttpSession(response) + + with patch("aiohttp.ClientSession", return_value=session): + result = self._run("tok", "111", "hi", thread_id="999") + + assert "error" in result + assert "500" in result["error"] + assert response.content.read_sizes[0] == _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES + 1 + assert response.closed is True + response.json.assert_not_awaited() + response.text.assert_not_awaited() + prefix = "Discord API error (500): " + assert len(result["error"].encode("utf-8")) <= ( + len(prefix.encode("utf-8")) + _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES + ) + class TestSendToPlatformDiscordThread: """_send_to_platform passes thread_id through to _send_discord.""" From f341cadb71843d021f87724062e551da2403e6d1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:19:18 -0700 Subject: [PATCH 084/610] refactor(discord): detect streaming bodies structurally, not by mock-module sniffing Replace the unittest.mock module-name check with an inspect.iscoroutinefunction probe on content.read, and collapse the duplicate read/iter_chunked reader paths into one. Non-streaming objects (test doubles, proxy wrappers) fall back to the response's native json()/text() as before. --- plugins/platforms/discord/adapter.py | 38 +++++++++++----------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 4f7e6694862a..1698f65d6c16 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -11,6 +11,7 @@ Uses discord.py library for: import asyncio import hashlib +import inspect import json import logging import os @@ -7471,10 +7472,6 @@ def _standalone_sanitize_error(text) -> str: ) -def _standalone_is_mock_object(value: Any) -> bool: - return value.__class__.__module__.startswith("unittest.mock") - - def _standalone_close_response(resp: Any) -> None: close = getattr(resp, "close", None) if callable(close): @@ -7489,12 +7486,19 @@ async def _standalone_read_response_bytes_limited( resp: Any, limit_bytes: int, ) -> Tuple[Optional[bytes], bool]: + """Read at most *limit_bytes* from an aiohttp-style response body. + + Returns ``(body, truncated)``. Returns ``(None, False)`` when the response + object does not expose a streaming ``content.read`` coroutine (e.g. a + proxy wrapper or test double) — callers fall back to the object's own + ``json()`` / ``text()`` in that case. + """ content = getattr(resp, "content", None) - if content is None or _standalone_is_mock_object(content): + read = getattr(content, "read", None) + if content is None or not inspect.iscoroutinefunction(read): return None, False - read = getattr(content, "read", None) - if callable(read): + try: chunks: list[bytes] = [] total = 0 while total <= limit_bytes: @@ -7509,22 +7513,10 @@ async def _standalone_read_response_bytes_limited( _standalone_close_response(resp) return b"".join(chunks)[:limit_bytes], True return b"".join(chunks), False - - iter_chunked = getattr(content, "iter_chunked", None) - if callable(iter_chunked): - chunks: list[bytes] = [] - total = 0 - async for chunk in iter_chunked(limit_bytes + 1): - if isinstance(chunk, str): - chunk = chunk.encode("utf-8", "replace") - total += len(chunk) - chunks.append(chunk) - if total > limit_bytes: - _standalone_close_response(resp) - return b"".join(chunks)[:limit_bytes], True - return b"".join(chunks), False - - return None, False + except (TypeError, AttributeError): + # Object quacked like a stream but wasn't one — let the caller use + # its native json()/text() instead of failing the send. + return None, False def _standalone_response_encoding(resp: Any) -> str: From ce038a0e0557f44fa049e4daab99f619f2b92f54 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:03:20 -0700 Subject: [PATCH 085/610] fix(schema): preserve multi-type arrays as anyOf instead of dropping branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port from anomalyco/opencode#31877: JSON Schema type arrays like ["number","string"] (common in MCP tool schemas) were collapsed to the first non-null type, silently dropping every other branch. Several tool-call backends reject the array form outright — llama.cpp's grammar generator and Gemini via OpenAI-compatible transports (e.g. GitHub Copilot proxying to Gemini) 400 on it. _sanitize_node now mirrors @ai-sdk/google: a single non-null type stays type:X (+nullable if null was present), multiple non-null types become an anyOf of single-type schemas so no branch is lost, and an all-null array becomes type:null. Single-null collapse is unchanged. Verified nested (object props, array items) survive the full sanitize pipeline — combinator stripping is top-level-only and nullable-union collapse only fires on single-survivor unions, so multi-type anyOf is left intact. --- tests/tools/test_schema_sanitizer.py | 59 ++++++++++++++++++++++++++++ tools/schema_sanitizer.py | 44 +++++++++++++++------ 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/tests/tools/test_schema_sanitizer.py b/tests/tools/test_schema_sanitizer.py index 360457de2b2a..90a6ffa3c048 100644 --- a/tests/tools/test_schema_sanitizer.py +++ b/tests/tools/test_schema_sanitizer.py @@ -80,6 +80,65 @@ def test_nullable_type_array_collapsed_to_single_string(): assert prop.get("nullable") is True +def test_multitype_array_becomes_anyof_no_branch_dropped(): + # Ported from anomalyco/opencode#31877: a genuine multi-type array such as + # ["number", "string"] (common in MCP tool schemas) must keep BOTH branches + # as an anyOf, not silently drop all but the first. Several backends + # (llama.cpp, Gemini via OpenAI-compatible transports) reject the array form. + tools = [_tool("t", { + "type": "object", + "properties": { + "status": {"type": ["number", "string"], "description": "status filter"}, + }, + })] + out = sanitize_tool_schemas(tools) + prop = out[0]["function"]["parameters"]["properties"]["status"] + assert "type" not in prop + assert prop["anyOf"] == [{"type": "number"}, {"type": "string"}] + assert prop.get("nullable") is None + # Sibling keywords survive alongside the generated anyOf. + assert prop["description"] == "status filter" + + +def test_multitype_array_with_null_lifts_nullable(): + tools = [_tool("t", { + "type": "object", + "properties": { + "v": {"type": ["integer", "boolean", "null"]}, + }, + })] + out = sanitize_tool_schemas(tools) + prop = out[0]["function"]["parameters"]["properties"]["v"] + assert "type" not in prop + assert prop["anyOf"] == [{"type": "integer"}, {"type": "boolean"}] + assert prop.get("nullable") is True + + +def test_all_null_type_array_becomes_null_type(): + tools = [_tool("t", { + "type": "object", + "properties": { + "n": {"type": ["null"]}, + }, + })] + out = sanitize_tool_schemas(tools) + prop = out[0]["function"]["parameters"]["properties"]["n"] + assert prop["type"] == "null" + + +def test_single_element_type_array_unwrapped(): + tools = [_tool("t", { + "type": "object", + "properties": { + "s": {"type": ["string"]}, + }, + })] + out = sanitize_tool_schemas(tools) + prop = out[0]["function"]["parameters"]["properties"]["s"] + assert prop["type"] == "string" + assert prop.get("nullable") is None + + def test_anyof_nested_objects_sanitized(): tools = [_tool("t", { "type": "object", diff --git a/tools/schema_sanitizer.py b/tools/schema_sanitizer.py index a2a5892d9270..9f0bc98fcd2c 100644 --- a/tools/schema_sanitizer.py +++ b/tools/schema_sanitizer.py @@ -235,7 +235,9 @@ def _sanitize_node(node: Any, path: str) -> Any: ``{"type": }`` so downstream consumers see a dict. - Injects ``properties: {}`` into object-typed nodes missing it. - Normalizes ``type: [X, "null"]`` arrays to single ``type: X`` (keeping - ``nullable: true`` as a hint). + ``nullable: true`` as a hint), and multi-type arrays like + ``["number", "string"]`` to an ``anyOf`` of single-type schemas so no + branch is dropped (ported from anomalyco/opencode#31877). - Recurses into ``properties``, ``items``, ``additionalProperties``, ``anyOf``, ``oneOf``, ``allOf``, and ``$defs`` / ``definitions``. """ @@ -268,23 +270,39 @@ def _sanitize_node(node: Any, path: str) -> Any: out: dict = {} for key, value in node.items(): - # type: [X, "null"] → type: X (the backend's tool-call parser only - # accepts singular string types; nullable is lost but the call still - # succeeds, and the model can still pass null on its own.) + # JSON Schema ``type`` arrays (e.g. ``["number", "string"]``, common + # in MCP tool schemas) are rejected by several tool-call backends: + # * llama.cpp's grammar generator only accepts a singular string type. + # * Gemini (including OpenAI-compatible transports such as GitHub + # Copilot proxying to Gemini) rejects the array form outright — + # plain @ai-sdk/google rewrites it, but the OpenAI-compatible path + # forwards it verbatim and the backend 400s. + # + # Normalize per the SDK's behavior: + # * single non-null type → ``type: X`` (+ ``nullable: true`` if the + # array also contained "null"). No data lost. + # * multiple non-null types → ``anyOf`` of single-type schemas, so + # EVERY branch survives instead of silently dropping all but the + # first. ``null`` is lifted into ``nullable: true``. + # * all-null / empty → ``type: "null"`` (or object fallback). + # Ported from anomalyco/opencode#31877. if key == "type" and isinstance(value, list): - non_null = [t for t in value if t != "null"] - if len(non_null) == 1 and isinstance(non_null[0], str): + has_null = "null" in value + non_null = [t for t in value if isinstance(t, str) and t != "null"] + if len(non_null) == 1: out["type"] = non_null[0] - if "null" in value: + if has_null: out.setdefault("nullable", True) continue - # Fallback: pick the first string type, drop the rest. - first_str = next((t for t in value if isinstance(t, str) and t != "null"), None) - if first_str: - out["type"] = first_str + if len(non_null) >= 2: + # Preserve all branches as a union instead of dropping them. + out["anyOf"] = [{"type": t} for t in non_null] + if has_null: + out.setdefault("nullable", True) continue - # All-null or empty list → treat as object. - out["type"] = "object" + # No usable non-null type: all-null array → type: "null"; + # otherwise an empty/garbage array → object fallback. + out["type"] = "null" if has_null else "object" continue if key in {"properties", "$defs", "definitions"} and isinstance(value, dict): From 4aaaa206aa7d08b1cf72acb84802a2f7d2fd95c0 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Mon, 6 Jul 2026 20:25:21 +0800 Subject: [PATCH 086/610] fix(telegram): add timeout to start_polling() in network error handler When the connection pool is in a degraded state after _drain_polling_connections(), start_polling() can hang indefinitely when both primary and fallback Telegram endpoints are unreachable. The httpx client may hold a stale socket that neither connects nor times out within PTB's internal flow, causing the reconnect ladder to stall at attempt 1/10 forever. Wrap start_polling() in asyncio.wait_for() with a 30-second timeout so a hung call raises asyncio.TimeoutError and feeds back into the existing retry ladder. This unblocks: - The 10-retry ladder advances to attempt 2, 3, ... - The heartbeat loop sees _polling_error_task.done() and can trigger recovery - The reconnect watcher gets the adapter in _failed_platforms Fixes #59614 --- plugins/platforms/telegram/adapter.py | 31 +++++- .../test_telegram_start_polling_timeout.py | 96 +++++++++++++++++++ 2 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 tests/gateway/test_telegram_start_polling_timeout.py diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index f3068018b070..a261d60fadb4 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -416,6 +416,12 @@ def _rich_normalize_linebreaks(text: str) -> str: # reconnect/teardown ladder. This is an internal safety bound (not a user knob), # applied identically at every stop() site so no path can hang on a dead socket. _UPDATER_STOP_TIMEOUT = 15.0 +# start_polling() can also hang when the connection pool is in a degraded state +# after _drain_polling_connections(), particularly when both primary and fallback +# Telegram endpoints are unreachable. Bounding start_polling() prevents the +# reconnect ladder from stalling indefinitely and allows the heartbeat loop to +# trigger its own recovery path. Refs: NousResearch/hermes-agent#59614 +_UPDATER_START_TIMEOUT = 30.0 class TelegramAdapter(BasePlatformAdapter): @@ -2072,11 +2078,26 @@ class TelegramAdapter(BasePlatformAdapter): try: if not app: raise RuntimeError("Telegram application was torn down during reconnect") - await app.updater.start_polling( - allowed_updates=Update.ALL_TYPES, - drop_pending_updates=False, - error_callback=self._polling_error_callback_ref, - ) + # Guard start_polling() with a timeout: when the connection pool is + # in a degraded state (e.g., after _drain_polling_connections()), the + # httpx client may hold a stale socket that neither connects nor times + # out within PTB's internal flow. Bounding start_polling() prevents + # the reconnect ladder from stalling indefinitely and allows the + # heartbeat loop to trigger its own recovery path. + # Refs: NousResearch/hermes-agent#59614 + try: + await asyncio.wait_for( + app.updater.start_polling( + allowed_updates=Update.ALL_TYPES, + drop_pending_updates=False, + error_callback=self._polling_error_callback_ref, + ), + timeout=_UPDATER_START_TIMEOUT, + ) + except asyncio.TimeoutError: + raise RuntimeError( + "start_polling() timed out — connection pool may be wedged" + ) logger.info( "[%s] Telegram polling resumed after network error (attempt %d)", self.name, attempt, diff --git a/tests/gateway/test_telegram_start_polling_timeout.py b/tests/gateway/test_telegram_start_polling_timeout.py new file mode 100644 index 000000000000..d89f587e0a73 --- /dev/null +++ b/tests/gateway/test_telegram_start_polling_timeout.py @@ -0,0 +1,96 @@ +"""Test that start_polling() timeout prevents indefinite hanging. + +This is a regression test for issue #59614 where start_polling() could hang +indefinitely when the connection pool is in a degraded state. +""" +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch +import pytest + +# Only import TelegramAdapter if it exists +try: + from hermes.plugins.platforms.telegram import adapter + _UPDATER_START_TIMEOUT = adapter._UPDATER_START_TIMEOUT +except (ImportError, AttributeError): + pytest.skip("Telegram adapter not available", allow_module_level=True) + + +class TestStartPollingTimeout: + """Test that start_polling() timeout prevents indefinite hanging.""" + + @pytest.mark.asyncio + async def test_start_polling_timeout_raises_runtime_error(self): + """When start_polling() times out, it should raise RuntimeError.""" + from hermes.plugins.platforms.telegram.adapter import TelegramAdapter + + # Mock the adapter's internal state + adapter = TelegramAdapter.__new__(TelegramAdapter) + adapter.name = "test_bot" + adapter.has_fatal_error = False + adapter._polling_network_error_count = 1 + adapter._polling_error_callback_ref = None + adapter._background_tasks = set() + adapter._send_path_degraded = True + + # Mock the app and updater + mock_app = MagicMock() + mock_updater = AsyncMock() + mock_app.updater = mock_updater + adapter._app = mock_app + + # Make start_polling() hang indefinitely (simulate the bug) + async def hanging_start_polling(**kwargs): + await asyncio.sleep(1000) # Hang for a long time + return None + + mock_updater.start_polling = hanging_start_polling + mock_updater.running = True + + # Mock _drain_polling_connections to avoid actual connection cleanup + with patch.object(adapter, '_drain_polling_connections', new=AsyncMock()): + # Trigger the network error handler + task = asyncio.create_task(adapter._handle_polling_network_error(Exception("test"))) + + # Wait for the timeout to trigger (start_polling_timeout is 30s) + try: + await asyncio.wait_for(task, timeout=_UPDATER_START_TIMEOUT + 5) + except asyncio.TimeoutError: + task.cancel() + pytest.fail("Network error handler did not complete within timeout") + + # The task should have completed (either with success or error) + # The important part is that it didn't hang forever + assert task.done() + + @pytest.mark.asyncio + async def test_start_polling_success_returns_normally(self): + """When start_polling() succeeds quickly, it should return normally.""" + from hermes.plugins.platforms.telegram.adapter import TelegramAdapter + + # Mock the adapter's internal state + adapter = TelegramAdapter.__new__(TelegramAdapter) + adapter.name = "test_bot" + adapter.has_fatal_error = False + adapter._polling_network_error_count = 1 + adapter._polling_error_callback_ref = None + adapter._background_tasks = set() + adapter._send_path_degraded = True + + # Mock the app and updater + mock_app = MagicMock() + mock_updater = AsyncMock() + mock_app.updater = mock_updater + adapter._app = mock_app + + # Make start_polling() succeed immediately + mock_updater.start_polling = AsyncMock(return_value=None) + mock_updater.running = True + + # Mock _drain_polling_connections and _verify_polling_after_reconnect + with patch.object(adapter, '_drain_polling_connections', new=AsyncMock()), \ + patch.object(adapter, '_verify_polling_after_reconnect', new=AsyncMock()): + # Trigger the network error handler + await adapter._handle_polling_network_error(Exception("test")) + + # Verify that start_polling was called + mock_updater.start_polling.assert_called_once() \ No newline at end of file From aaeba213d90c7234772280a335e6324659c004f9 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:19:31 +0530 Subject: [PATCH 087/610] fix(telegram): bound start_polling() at bootstrap and conflict-retry sites too; strengthen tests Follow-up on the salvaged fix, which bounded start_polling() only in _handle_polling_network_error. The same wedge (#59614) exists at the two sibling call sites: 1. _start_polling_resilient (bootstrap): an exhausted pool hangs connect() forever. The TimeoutError from wait_for is a builtins TimeoutError (OSError subclass), so the existing except classifies it via _looks_like_network_error and schedules background recovery. 2. _handle_polling_conflict (conflict-retry ladder): identical hang wedges conflict attempt N forever; timeout now converts to RuntimeError and the existing except schedules the next attempt. Tests replaced with a stronger suite: hung-network-ladder repro (RED without the fix), bootstrap hang schedules recovery, success-path sanity, and a bug-class contract test asserting EVERY updater.start_polling( call site is wrapped in wait_for so a new unbounded site can't reintroduce the wedge. Verified RED (3 failures) with the wrappers removed, GREEN with them. --- plugins/platforms/telegram/adapter.py | 40 +++- .../test_telegram_start_polling_timeout.py | 209 +++++++++++------- 2 files changed, 166 insertions(+), 83 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index a261d60fadb4..fd5fc0177eba 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -1979,10 +1979,19 @@ class TelegramAdapter(BasePlatformAdapter): if not (self._app and self._app.updater): raise RuntimeError("Telegram application/updater not initialized") try: - await self._app.updater.start_polling( - allowed_updates=Update.ALL_TYPES, - drop_pending_updates=drop_pending_updates, - error_callback=error_callback, + # Same watchdog bound as the reconnect ladders: a wedged httpx + # connection pool can hang start_polling() forever at bootstrap + # too (#59614). A propagating TimeoutError is a builtins + # TimeoutError (OSError subclass), so the except below classifies + # it via _looks_like_network_error and schedules background + # recovery instead of blocking connect() indefinitely. + await asyncio.wait_for( + self._app.updater.start_polling( + allowed_updates=Update.ALL_TYPES, + drop_pending_updates=drop_pending_updates, + error_callback=error_callback, + ), + timeout=_UPDATER_START_TIMEOUT, ) return True except Exception as err: @@ -2488,11 +2497,24 @@ class TelegramAdapter(BasePlatformAdapter): try: if not app: raise RuntimeError("Telegram application was torn down during conflict reconnect") - await app.updater.start_polling( - allowed_updates=Update.ALL_TYPES, - drop_pending_updates=False, - error_callback=self._polling_error_callback_ref, - ) + # Same watchdog bound as the network-error ladder: an + # exhausted pool hangs start_polling() on the conflict path + # identically (#59614). Timeout converts to RuntimeError so + # the except below logs a readable message and schedules the + # next conflict attempt instead of wedging attempt N forever. + try: + await asyncio.wait_for( + app.updater.start_polling( + allowed_updates=Update.ALL_TYPES, + drop_pending_updates=False, + error_callback=self._polling_error_callback_ref, + ), + timeout=_UPDATER_START_TIMEOUT, + ) + except asyncio.TimeoutError: + raise RuntimeError( + "start_polling() timed out — connection pool may be wedged" + ) logger.info( "[%s] Telegram polling resumed after conflict retry %d/%d", self.name, self._polling_conflict_count, MAX_CONFLICT_RETRIES, diff --git a/tests/gateway/test_telegram_start_polling_timeout.py b/tests/gateway/test_telegram_start_polling_timeout.py index d89f587e0a73..12e4ef97b7df 100644 --- a/tests/gateway/test_telegram_start_polling_timeout.py +++ b/tests/gateway/test_telegram_start_polling_timeout.py @@ -1,96 +1,157 @@ -"""Test that start_polling() timeout prevents indefinite hanging. +"""Regression tests for #59614: start_polling() must be time-bounded. -This is a regression test for issue #59614 where start_polling() could hang -indefinitely when the connection pool is in a degraded state. +When both the primary Telegram API server and all fallback IPs are unreachable, +``await app.updater.start_polling(...)`` can block forever inside an exhausted +httpx connection pool — it neither returns nor raises. Unbounded, that wedges: + +1. the network-error reconnect ladder (stuck inside attempt 1, never advances), +2. the heartbeat loop (sees the recovery task as alive-but-wedged and skips), +3. the fatal-error escalation (never reached). + +The fix wraps every ``start_polling()`` await in ``asyncio.wait_for`` with +``_UPDATER_START_TIMEOUT`` so a hung call raises and feeds the existing retry +ladder. These tests patch the timeout down to keep the suite fast. """ import asyncio +import sys from unittest.mock import AsyncMock, MagicMock, patch + import pytest -# Only import TelegramAdapter if it exists -try: - from hermes.plugins.platforms.telegram import adapter - _UPDATER_START_TIMEOUT = adapter._UPDATER_START_TIMEOUT -except (ImportError, AttributeError): - pytest.skip("Telegram adapter not available", allow_module_level=True) + +def _ensure_telegram_mock(): + if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): + return + telegram_mod = MagicMock() + telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None) + telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" + telegram_mod.constants.ChatType.GROUP = "group" + telegram_mod.constants.ChatType.SUPERGROUP = "supergroup" + telegram_mod.constants.ChatType.CHANNEL = "channel" + telegram_mod.constants.ChatType.PRIVATE = "private" + telegram_mod.error.NetworkError = type("NetworkError", (OSError,), {}) + telegram_mod.error.TimedOut = type("TimedOut", (OSError,), {}) + for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): + sys.modules.setdefault(name, telegram_mod) + sys.modules.setdefault("telegram.error", telegram_mod.error) -class TestStartPollingTimeout: - """Test that start_polling() timeout prevents indefinite hanging.""" +_ensure_telegram_mock() - @pytest.mark.asyncio - async def test_start_polling_timeout_raises_runtime_error(self): - """When start_polling() times out, it should raise RuntimeError.""" - from hermes.plugins.platforms.telegram.adapter import TelegramAdapter +from plugins.platforms.telegram import adapter as tg_adapter # noqa: E402 +from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402 - # Mock the adapter's internal state - adapter = TelegramAdapter.__new__(TelegramAdapter) - adapter.name = "test_bot" - adapter.has_fatal_error = False - adapter._polling_network_error_count = 1 - adapter._polling_error_callback_ref = None - adapter._background_tasks = set() - adapter._send_path_degraded = True - # Mock the app and updater - mock_app = MagicMock() - mock_updater = AsyncMock() - mock_app.updater = mock_updater - adapter._app = mock_app +async def _hang_forever(**kwargs): + await asyncio.sleep(1000) - # Make start_polling() hang indefinitely (simulate the bug) - async def hanging_start_polling(**kwargs): - await asyncio.sleep(1000) # Hang for a long time - return None - mock_updater.start_polling = hanging_start_polling - mock_updater.running = True +def _bare_adapter(): + a = TelegramAdapter.__new__(TelegramAdapter) + # `name` / `has_fatal_error` are read-only base-class properties; set the + # backing fields they derive from instead. + from gateway.config import Platform - # Mock _drain_polling_connections to avoid actual connection cleanup - with patch.object(adapter, '_drain_polling_connections', new=AsyncMock()): - # Trigger the network error handler - task = asyncio.create_task(adapter._handle_polling_network_error(Exception("test"))) + a.platform = Platform.TELEGRAM + a._fatal_error_code = None + a._fatal_error_message = None + a._fatal_error_retryable = True + a._polling_network_error_count = 0 + a._polling_conflict_count = 0 + a._polling_error_callback_ref = None + a._background_tasks = set() + a._send_path_degraded = False + return a - # Wait for the timeout to trigger (start_polling_timeout is 30s) - try: - await asyncio.wait_for(task, timeout=_UPDATER_START_TIMEOUT + 5) - except asyncio.TimeoutError: - task.cancel() - pytest.fail("Network error handler did not complete within timeout") - # The task should have completed (either with success or error) - # The important part is that it didn't hang forever - assert task.done() +@pytest.mark.asyncio +async def test_network_ladder_start_polling_hang_does_not_wedge(monkeypatch): + """A hung start_polling() in _handle_polling_network_error must time out + and advance the ladder instead of blocking forever (#59614 core repro).""" + monkeypatch.setattr(tg_adapter, "_UPDATER_START_TIMEOUT", 0.2) + a = _bare_adapter() + a._polling_network_error_count = 0 # attempt 1 → 5s backoff before start_polling - @pytest.mark.asyncio - async def test_start_polling_success_returns_normally(self): - """When start_polling() succeeds quickly, it should return normally.""" - from hermes.plugins.platforms.telegram.adapter import TelegramAdapter + app = MagicMock() + app.updater = AsyncMock() + app.updater.start_polling = _hang_forever + app.updater.running = False + a._app = app - # Mock the adapter's internal state - adapter = TelegramAdapter.__new__(TelegramAdapter) - adapter.name = "test_bot" - adapter.has_fatal_error = False - adapter._polling_network_error_count = 1 - adapter._polling_error_callback_ref = None - adapter._background_tasks = set() - adapter._send_path_degraded = True + with patch.object(a, "_drain_polling_connections", new=AsyncMock()), \ + patch.object( + tg_adapter.asyncio, "ensure_future", + side_effect=lambda coro: (coro.close(), asyncio.get_event_loop().create_future())[1], + ): + # Unbounded, this await hangs past the 30s wait_for and fails the + # test; bounded, the handler waits its 5s backoff, times out the hung + # start_polling() in 0.2s, schedules the chained retry (captured by + # the ensure_future patch), and returns. + await asyncio.wait_for( + a._handle_polling_network_error(Exception("net down")), timeout=30 + ) - # Mock the app and updater - mock_app = MagicMock() - mock_updater = AsyncMock() - mock_app.updater = mock_updater - adapter._app = mock_app - # Make start_polling() succeed immediately - mock_updater.start_polling = AsyncMock(return_value=None) - mock_updater.running = True +@pytest.mark.asyncio +async def test_bootstrap_start_polling_hang_schedules_recovery(monkeypatch): + """_start_polling_resilient: a hung bootstrap start_polling() must raise + TimeoutError (an OSError → classified as network error) and schedule + background recovery instead of blocking connect() forever.""" + monkeypatch.setattr(tg_adapter, "_UPDATER_START_TIMEOUT", 0.2) + a = _bare_adapter() - # Mock _drain_polling_connections and _verify_polling_after_reconnect - with patch.object(adapter, '_drain_polling_connections', new=AsyncMock()), \ - patch.object(adapter, '_verify_polling_after_reconnect', new=AsyncMock()): - # Trigger the network error handler - await adapter._handle_polling_network_error(Exception("test")) + app = MagicMock() + app.updater = AsyncMock() + app.updater.start_polling = _hang_forever + a._app = app - # Verify that start_polling was called - mock_updater.start_polling.assert_called_once() \ No newline at end of file + scheduled = [] + monkeypatch.setattr( + a, "_schedule_polling_recovery", + lambda err, reason: scheduled.append((err, reason)), + raising=False, + ) + + ok = await asyncio.wait_for( + a._start_polling_resilient(drop_pending_updates=False, error_callback=None), + timeout=10, + ) + assert ok is False + assert len(scheduled) == 1 + assert isinstance(scheduled[0][0], (TimeoutError, asyncio.TimeoutError)) + + +@pytest.mark.asyncio +async def test_start_polling_success_path_unaffected(monkeypatch): + """Sanity: a fast start_polling() still returns True through the wrapper.""" + monkeypatch.setattr(tg_adapter, "_UPDATER_START_TIMEOUT", 5.0) + a = _bare_adapter() + + app = MagicMock() + app.updater = AsyncMock() + app.updater.start_polling = AsyncMock(return_value=None) + a._app = app + + ok = await a._start_polling_resilient(drop_pending_updates=False, error_callback=None) + assert ok is True + app.updater.start_polling.assert_awaited_once() + + +def test_every_start_polling_call_site_is_time_bounded(): + """Bug-class contract: every `updater.start_polling(` await in the adapter + must be wrapped in asyncio.wait_for. A new unbounded call site reintroduces + the #59614 wedge.""" + import inspect + import re + + src = inspect.getsource(tg_adapter) + # Find each start_polling( call and check an enclosing wait_for within the + # preceding 6 lines (the wrapper always sits directly above). + lines = src.splitlines() + unbounded = [] + for i, line in enumerate(lines): + if re.search(r"updater\.start_polling\(", line) and "def " not in line: + window = "\n".join(lines[max(0, i - 6):i + 1]) + if "wait_for" not in window: + unbounded.append((i + 1, line.strip())) + assert not unbounded, f"unbounded start_polling() call sites: {unbounded}" From 2718179134a555bf2ff92ab9b5491a9548b85b6e Mon Sep 17 00:00:00 2001 From: floit Date: Sat, 6 Jun 2026 10:19:29 +0200 Subject: [PATCH 088/610] fix(docs): discord permissions (add Create Public Threads, remove Use External Emojis) --- website/docs/user-guide/features/voice-mode.md | 6 +++--- website/docs/user-guide/messaging/discord.md | 7 ++++--- .../current/user-guide/features/voice-mode.md | 8 ++++---- .../current/user-guide/messaging/discord.md | 9 +++++---- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/website/docs/user-guide/features/voice-mode.md b/website/docs/user-guide/features/voice-mode.md index f32d96d151c4..14a4235e3e7c 100644 --- a/website/docs/user-guide/features/voice-mode.md +++ b/website/docs/user-guide/features/voice-mode.md @@ -263,13 +263,13 @@ Go to the [Discord Developer Portal](https://discord.com/developers/applications | Level | Integer | What's Included | |-------|---------|----------------| -| Text only | `274878286912` | View Channels, Send Messages, Read History, Embeds, Attachments, Threads, Reactions | -| Text + Voice | `274881432640` | All above + Connect, Speak | +| Text only | `309237763136` | View Channels, Send Messages, Read History, Embeds, Attachments, Threads, Reactions, Create Public Threads | +| Text + Voice | `309240908864` | All above + Connect, Speak | **Re-invite the bot** with the updated permissions URL: ``` -https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot+applications.commands&permissions=274881432640 +https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot+applications.commands&permissions=309240908864 ``` Replace `YOUR_APP_ID` with your Application ID from the Developer Portal. diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 20288cefa9c0..030e69681e5e 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -114,7 +114,7 @@ This is the most critical step in the entire setup. Without the correct intents On the **Bot** page, scroll down to **Privileged Gateway Intents**. You'll see three toggles: | Intent | Purpose | Required? | -|--------|---------|-----------| +|--------|---------|-----------| | **Presence Intent** | See user online/offline status | Optional | | **Server Members Intent** | Access the member list, resolve usernames | **Required** | | **Message Content Intent** | Read the text content of messages | **Required** | @@ -170,7 +170,7 @@ This method requires **Public Bot** to be set to **ON** in Step 2. If you set Pu You can construct the invite URL directly using this format: ``` -https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot+applications.commands&permissions=274878286912 +https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot+applications.commands&permissions=309237763136 ``` Replace `YOUR_APP_ID` with the Application ID from Step 1. @@ -187,6 +187,7 @@ These are the minimum permissions your bot needs: ### Recommended Additional Permissions +- **Create Public Threads** - create threads - **Send Messages in Threads** — respond in thread conversations - **Add Reactions** — react to messages for acknowledgment @@ -195,7 +196,7 @@ These are the minimum permissions your bot needs: | Level | Permissions Integer | What's Included | |-------|-------------------|-----------------| | Minimal | `117760` | View Channels, Send Messages, Read Message History, Attach Files | -| Recommended | `274878286912` | All of the above plus Embed Links, Send Messages in Threads, Add Reactions | +| Recommended | `309237763136` | All of the above plus Embed Links, Send Messages in Threads, Add Reactions, Create Public Threads | ## Step 6: Invite to Your Server diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/voice-mode.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/voice-mode.md index 7e9c40beff9a..1138d8aa9200 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/voice-mode.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/voice-mode.md @@ -263,13 +263,13 @@ DISCORD_FREE_RESPONSE_CHANNELS=123456789,987654321 | 级别 | 整数 | 包含内容 | |-------|---------|----------------| -| 仅文字 | `274878286912` | 查看频道、发送消息、读取历史、嵌入内容、附件、帖子、反应 | -| 文字 + 语音 | `274881432640` | 以上所有 + Connect、Speak | +| 仅文字 | `309237763136` | 查看频道、发送消息、读取历史、嵌入内容、附件、帖子、反应、创建公开帖子 | +| 文字 + 语音 | `309240908864` | 以上所有 + Connect、Speak | **使用更新后的权限 URL 重新邀请 Bot:** ``` -https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot+applications.commands&permissions=274881432640 +https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot+applications.commands&permissions=309240908864 ``` 将 `YOUR_APP_ID` 替换为开发者门户中的应用 ID。 @@ -517,4 +517,4 @@ Bot 在服务器频道中默认需要 @提及。请确认: - 在更安静的环境中使用 - 在配置中调高 `silence_threshold`(值越高,灵敏度越低) -- 尝试不同的 STT 模型 \ No newline at end of file +- 尝试不同的 STT 模型 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/discord.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/discord.md index ebb64a76cd4c..77b23996b2a6 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/discord.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/discord.md @@ -114,7 +114,7 @@ Hermes 按会话键跟踪正在运行的 agent。 在 **Bot** 页面,向下滚动到 **Privileged Gateway Intents**。你会看到三个开关: | Intent | 用途 | 是否必需? | -|--------|---------|-----------| +|--------|---------|-----------| | **Presence Intent** | 查看用户在线/离线状态 | 可选 | | **Server Members Intent** | 访问成员列表、解析用户名 | **必需** | | **Message Content Intent** | 读取消息的文本内容 | **必需** | @@ -170,7 +170,7 @@ Token 只显示一次。如果丢失,你需要重置并生成新的 token。 你可以使用以下格式直接构建邀请 URL: ``` -https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot+applications.commands&permissions=274878286912 +https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot+applications.commands&permissions=309237763136 ``` 将 `YOUR_APP_ID` 替换为第一步中的 Application ID。 @@ -188,6 +188,7 @@ https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot+application ### 推荐的附加权限 - **Send Messages in Threads** — 在线程对话中响应 +- **Create Public Threads** - create threads - **Add Reactions** — 对消息添加反应以示确认 ### 权限整数 @@ -195,7 +196,7 @@ https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot+application | 级别 | 权限整数 | 包含内容 | |-------|-------------------|-----------------| | 最低 | `117760` | View Channels、Send Messages、Read Message History、Attach Files | -| 推荐 | `274878286912` | 以上所有权限,加上 Embed Links、Send Messages in Threads、Add Reactions | +| 推荐 | `309237763136` | 以上所有权限,加上 Embed Links、Send Messages in Threads、Add Reactions, Create Public Threads | ## 第六步:邀请到你的服务器 @@ -796,4 +797,4 @@ DISCORD_ALLOW_MENTION_REPLIED_USER=true 除非你确切知道为什么需要,否则将 `everyone` 和 `roles` 保持为 `false`。LLM 很容易在看似正常的响应中生成字符串 `@everyone`;没有此保护,这将通知你服务器的每个成员。 ::: -有关保护 Hermes Agent 部署的更多信息,请参阅[安全指南](../security.md)。 \ No newline at end of file +有关保护 Hermes Agent 部署的更多信息,请参阅[安全指南](../security.md)。 From b2c66681c4378c457dbdf3d1d7ca206bad03c2ad Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:45:26 -0700 Subject: [PATCH 089/610] chore: add flo1t to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index b7895aa051be..a1a699e5e39c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -290,6 +290,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "florian.rutishauser@outlook.com": "flo1t", "fanyang@microsoft.com": "fanyangCS", "bigstar0920@gmail.com": "bigstar0920", "hello@tanmaychoudhary.com": "tanmayxchoudhary", From 569b78c1f96573584f27cd0025d945e2434d6de1 Mon Sep 17 00:00:00 2001 From: ygd58 Date: Wed, 25 Mar 2026 20:36:51 +0100 Subject: [PATCH 090/610] fix(setup): bootstrap pip with ensurepip when not available in venv before neutts install --- hermes_cli/setup.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 7cdbeabe8e9b..63a9372adf93 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -828,6 +828,19 @@ def _install_neutts_deps() -> bool: print_info("Installing neutts Python package...") print_info("This will also download the TTS model (~300MB) on first use.") print() + + # Ensure pip is available — some venvs are created without pip (e.g. Ubuntu 25.10) + try: + import importlib + if importlib.util.find_spec("pip") is None: + print_info("pip not found in venv — bootstrapping with ensurepip...") + subprocess.run([sys.executable, "-m", "ensurepip", "--upgrade"], check=True, timeout=30) + print_success("pip bootstrapped successfully") + except Exception as e: + print_warning(f"Could not bootstrap pip: {e}") + print_info("Try: python -m ensurepip --upgrade") + return False + try: subprocess.run( [sys.executable, "-m", "pip", "install", "-U", "neutts[all]", "--quiet"], From ba865e40388f31c34e4ab7aca9be764ca7e0d078 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:25:24 -0700 Subject: [PATCH 091/610] =?UTF-8?q?refactor(setup):=20route=20dependency?= =?UTF-8?q?=20installs=20through=20the=20canonical=20uv=E2=86=92pip?= =?UTF-8?q?=E2=86=92ensurepip=20ladder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled ensurepip bootstrap (and five other one-off pip-install code paths) with hermes_cli.tools_config._pip_install, which prefers the bundled uv (fast, needs no pip in the venv), falls back to python -m pip, and bootstraps pip via ensurepip only when missing. Sites unified: - hermes_cli/setup.py: _install_neutts_deps, _install_kittentts_deps, modal SDK install, daytona SDK install - hermes_cli/memory_setup.py: memory-plugin pip deps (previously dead-ended when uv AND pip binaries were both absent) - hermes_cli/dingtalk_auth.py: qrcode auto-install (previously invoked 'python -m uv' which is not how uv ships) - agent/lsp/install.py: --target LSP server installs - plugins/google_meet/cli.py, plugins/platforms/matrix/adapter.py, plugins/platforms/google_chat/oauth.py, plugins/memory/honcho/cli.py Tests updated to assert the ladder behavior (uv-first, pip fallback, ensurepip bootstrap) instead of the removed bespoke branches. --- agent/lsp/install.py | 12 +-- hermes_cli/dingtalk_auth.py | 16 ++-- hermes_cli/memory_setup.py | 39 +++----- hermes_cli/setup.py | 96 +++++++------------ plugins/google_meet/cli.py | 7 +- plugins/memory/honcho/cli.py | 14 +-- plugins/platforms/google_chat/oauth.py | 11 ++- plugins/platforms/matrix/adapter.py | 17 +--- .../test_memory_setup_provider_arg.py | 60 +++++++----- 9 files changed, 107 insertions(+), 165 deletions(-) diff --git a/agent/lsp/install.py b/agent/lsp/install.py index 2cba93723337..079033b772ad 100644 --- a/agent/lsp/install.py +++ b/agent/lsp/install.py @@ -348,17 +348,15 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]: pip_target.mkdir(parents=True, exist_ok=True) try: logger.info("[install] pip install --target %s %s", pip_target, pkg) - proc = subprocess.run( - [sys.executable, "-m", "pip", "install", "--target", str(pip_target), "--quiet", pkg], - check=False, - capture_output=True, - text=True, + from hermes_cli.tools_config import _pip_install + + proc = _pip_install( + ["--target", str(pip_target), "--quiet", pkg], timeout=300, - stdin=subprocess.DEVNULL, ) if proc.returncode != 0: logger.warning( - "[install] pip install failed for %s: %s", pkg, proc.stderr.strip()[:500] + "[install] pip install failed for %s: %s", pkg, (proc.stderr or "").strip()[:500] ) return None except (subprocess.TimeoutExpired, OSError) as e: diff --git a/hermes_cli/dingtalk_auth.py b/hermes_cli/dingtalk_auth.py index 710be7754963..3c397759d51c 100644 --- a/hermes_cli/dingtalk_auth.py +++ b/hermes_cli/dingtalk_auth.py @@ -163,17 +163,15 @@ def _ensure_qrcode_installed() -> bool: import subprocess - # Try uv first (Hermes convention), then pip - for cmd in ( - [sys.executable, "-m", "uv", "pip", "install", "qrcode"], - [sys.executable, "-m", "pip", "install", "-q", "qrcode"], - ): - try: - subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + from hermes_cli.tools_config import _pip_install + + try: + result = _pip_install(["-q", "qrcode"], timeout=120) + if result.returncode == 0: import qrcode # noqa: F401,F811 return True - except (subprocess.CalledProcessError, ImportError, FileNotFoundError): - continue + except (subprocess.SubprocessError, ImportError, OSError): + pass return False diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index c4574affe39a..2f6801de3992 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -122,36 +122,19 @@ def _install_dependencies(provider_name: str) -> None: print(f"\n Installing dependencies: {', '.join(missing)}") - import shutil - - uv_path = shutil.which("uv") - if uv_path: - install_cmd = [uv_path, "pip", "install", "--python", sys.executable, "--quiet"] + missing - manual_cmd = f"uv pip install --python {sys.executable} {' '.join(missing)}" - else: - pip_cmd = shutil.which("pip3") or shutil.which("pip") - if not pip_cmd: - print(" ⚠ uv not found — cannot install dependencies") - print(" Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh") - print(" Then re-run: hermes memory setup") - return - print(" ⚠ uv not found. Falling back to standard pip...") - install_cmd = [sys.executable, "-m", "pip", "install", "--quiet"] + missing - manual_cmd = f"{sys.executable} -m pip install {' '.join(missing)}" + from hermes_cli.tools_config import _pip_install + manual_cmd = f"uv pip install {' '.join(missing)}" try: - subprocess.run( - install_cmd, - check=True, timeout=120, - capture_output=True, - ) - print(f" ✓ Installed {', '.join(missing)}") - except subprocess.CalledProcessError as e: - print(f" ⚠ Failed to install {', '.join(missing)}") - stderr = (e.stderr or b"").decode()[:200] - if stderr: - print(f" {stderr}") - print(f" Run manually: {manual_cmd}") + result = _pip_install(["--quiet"] + missing, timeout=120) + if result.returncode == 0: + print(f" ✓ Installed {', '.join(missing)}") + else: + print(f" ⚠ Failed to install {', '.join(missing)}") + stderr = (result.stderr or "")[:200] + if stderr: + print(f" {stderr}") + print(f" Run manually: {manual_cmd}") except Exception as e: print(f" ⚠ Install failed: {e}") print(f" Run manually: {manual_cmd}") diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 63a9372adf93..fcdba27165f6 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -829,29 +829,23 @@ def _install_neutts_deps() -> bool: print_info("This will also download the TTS model (~300MB) on first use.") print() - # Ensure pip is available — some venvs are created without pip (e.g. Ubuntu 25.10) - try: - import importlib - if importlib.util.find_spec("pip") is None: - print_info("pip not found in venv — bootstrapping with ensurepip...") - subprocess.run([sys.executable, "-m", "ensurepip", "--upgrade"], check=True, timeout=30) - print_success("pip bootstrapped successfully") - except Exception as e: - print_warning(f"Could not bootstrap pip: {e}") - print_info("Try: python -m ensurepip --upgrade") - return False + # Route through the canonical uv → pip → ensurepip ladder so pip-less + # venvs (Ubuntu 25.10 `python -m venv`, `uv venv`) work out of the box. + from hermes_cli.tools_config import _pip_install try: - subprocess.run( - [sys.executable, "-m", "pip", "install", "-U", "neutts[all]", "--quiet"], - check=True, timeout=300, - ) + result = _pip_install(["-U", "neutts[all]", "--quiet"], timeout=300) + except Exception as e: + print_error(f"Failed to install neutts: {e}") + print_info("Try manually: uv pip install -U 'neutts[all]'") + return False + if result.returncode == 0: print_success("neutts installed successfully") return True - except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: - print_error(f"Failed to install neutts: {e}") - print_info("Try manually: python -m pip install -U neutts[all]") - return False + err = (result.stderr or "").strip() + print_error(f"Failed to install neutts: {err[:300] if err else 'install failed'}") + print_info("Try manually: uv pip install -U 'neutts[all]'") + return False def _install_kittentts_deps() -> bool: @@ -866,17 +860,22 @@ def _install_kittentts_deps() -> bool: print() print_info("Installing kittentts Python package (~25-80MB model downloaded on first use)...") print() + + from hermes_cli.tools_config import _pip_install + try: - subprocess.run( - [sys.executable, "-m", "pip", "install", "-U", wheel_url, "soundfile", "--quiet"], - check=True, timeout=300, - ) + result = _pip_install(["-U", wheel_url, "soundfile", "--quiet"], timeout=300) + except Exception as e: + print_error(f"Failed to install kittentts: {e}") + print_info(f"Try manually: uv pip install -U '{wheel_url}' soundfile") + return False + if result.returncode == 0: print_success("kittentts installed successfully") return True - except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: - print_error(f"Failed to install kittentts: {e}") - print_info(f"Try manually: python -m pip install -U '{wheel_url}' soundfile") - return False + err = (result.stderr or "").strip() + print_error(f"Failed to install kittentts: {err[:300] if err else 'install failed'}") + print_info(f"Try manually: uv pip install -U '{wheel_url}' soundfile") + return False def _xai_oauth_logged_in_for_setup() -> bool: @@ -1315,32 +1314,13 @@ def setup_terminal_backend(config: dict): __import__("modal") except ImportError: print_info("Installing modal SDK...") - import subprocess + from hermes_cli.tools_config import _pip_install - uv_bin = shutil.which("uv") - if uv_bin: - result = subprocess.run( - [ - uv_bin, - "pip", - "install", - "--python", - sys.executable, - "modal", - ], - capture_output=True, - text=True, - ) - else: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "modal"], - capture_output=True, - text=True, - ) + result = _pip_install(["modal"]) if result.returncode == 0: print_success("modal SDK installed") else: - print_warning("Install failed — run manually: pip install modal") + print_warning("Install failed — run manually: uv pip install modal") # Modal token print() @@ -1375,25 +1355,13 @@ def setup_terminal_backend(config: dict): __import__("daytona") except ImportError: print_info("Installing daytona SDK...") - import subprocess + from hermes_cli.tools_config import _pip_install - uv_bin = shutil.which("uv") - if uv_bin: - result = subprocess.run( - [uv_bin, "pip", "install", "--python", sys.executable, "daytona"], - capture_output=True, - text=True, - ) - else: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "daytona"], - capture_output=True, - text=True, - ) + result = _pip_install(["daytona"]) if result.returncode == 0: print_success("daytona SDK installed") else: - print_warning("Install failed — run manually: pip install daytona") + print_warning("Install failed — run manually: uv pip install daytona") if result.stderr: print_info(f" Error: {result.stderr.strip().splitlines()[-1]}") diff --git a/plugins/google_meet/cli.py b/plugins/google_meet/cli.py index 1e5f91d01773..1ab0c0fd73db 100644 --- a/plugins/google_meet/cli.py +++ b/plugins/google_meet/cli.py @@ -250,10 +250,9 @@ def _cmd_install(*, realtime: bool, assume_yes: bool) -> int: pip_pkgs = ["playwright", "websockets"] print(f"\n[1/3] pip install: {' '.join(pip_pkgs)}") try: - res = _sp.run( - [sys.executable, "-m", "pip", "install", "--upgrade", *pip_pkgs], - check=False, - ) + from hermes_cli.tools_config import _pip_install + + res = _pip_install(["--upgrade", *pip_pkgs], capture_output=False) if res.returncode != 0: print(" pip install failed") return 1 diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index d70a13e146b1..3db0cc3fd684 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -515,20 +515,16 @@ def _ensure_sdk_installed() -> bool: print(" Skipping install. Run: pip install 'honcho-ai>=2.0.1'\n") return False - import subprocess print(" Installing honcho-ai...", flush=True) - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "honcho-ai>=2.0.1"], - capture_output=True, - text=True, - stdin=subprocess.DEVNULL, - ) + from hermes_cli.tools_config import _pip_install + + result = _pip_install(["honcho-ai>=2.0.1"]) if result.returncode == 0: print(" Installed.\n") return True else: - print(f" Install failed:\n{result.stderr.strip()}") - print(" Run manually: pip install 'honcho-ai>=2.0.1'\n") + print(f" Install failed:\n{(result.stderr or '').strip()}") + print(" Run manually: uv pip install 'honcho-ai>=2.0.1'\n") return False diff --git a/plugins/platforms/google_chat/oauth.py b/plugins/platforms/google_chat/oauth.py index 3d481b3ead7b..277b2396f0c5 100644 --- a/plugins/platforms/google_chat/oauth.py +++ b/plugins/platforms/google_chat/oauth.py @@ -379,13 +379,14 @@ def install_deps() -> bool: print("Installing Google Chat OAuth dependencies...") try: - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "--quiet"] + _REQUIRED_PACKAGES, - stdout=subprocess.DEVNULL, - ) + from hermes_cli.tools_config import _pip_install + + result = _pip_install(["--quiet"] + _REQUIRED_PACKAGES) + if result.returncode != 0: + raise RuntimeError((result.stderr or "install failed").strip()[:300]) print("Dependencies installed.") return True - except subprocess.CalledProcessError as exc: + except Exception as exc: print(f"ERROR: Failed to install dependencies: {exc}") print("Or install via the optional extra:") print(" pip install 'hermes-agent[google_chat]'") diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index dac4dbd1665c..d66a1d87b76e 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -4497,23 +4497,14 @@ def interactive_setup() -> None: __import__("mautrix") except ImportError: print_info(f"Installing {matrix_pkg}...") - import subprocess - uv_bin = shutil.which("uv") - if uv_bin: - result = subprocess.run( - [uv_bin, "pip", "install", "--python", _sys.executable, matrix_pkg], - capture_output=True, text=True, - ) - else: - result = subprocess.run( - [_sys.executable, "-m", "pip", "install", matrix_pkg], - capture_output=True, text=True, - ) + from hermes_cli.tools_config import _pip_install + + result = _pip_install([matrix_pkg]) if result.returncode == 0: print_success(f"{matrix_pkg} installed") else: print_warning( - f"Install failed — run manually: pip install " + f"Install failed — run manually: uv pip install " f"'{matrix_pkg}' asyncpg aiosqlite Markdown aiohttp-socks" ) diff --git a/tests/hermes_cli/test_memory_setup_provider_arg.py b/tests/hermes_cli/test_memory_setup_provider_arg.py index 7e7ff6ba3287..afeade9ddbd0 100644 --- a/tests/hermes_cli/test_memory_setup_provider_arg.py +++ b/tests/hermes_cli/test_memory_setup_provider_arg.py @@ -51,47 +51,55 @@ class TestMemorySetupProviderRouting: class TestInstallDependenciesRunner: - """`_install_dependencies` must install via `uv` when present and fall back - to standard `pip` when `uv` is unavailable (e.g. slim containers / CI images - that don't ship uv) instead of dead-ending with "cannot install".""" + """`_install_dependencies` must route through the canonical + ``_pip_install`` ladder (uv → pip → ensurepip): uv when present, standard + pip when uv is unavailable, and an ensurepip bootstrap for pip-less venvs + instead of dead-ending with "cannot install".""" - def _run_with_missing_dep(self, tmp_path, which_side_effect): + def _run_with_missing_dep(self, tmp_path, which_side_effect, run_behavior=None): """Drive _install_dependencies for a plugin that declares one missing - pip dep, capturing the subprocess.run argv (or None if never called).""" + pip dep, capturing every subprocess.run argv issued by the ladder.""" import sys (tmp_path / "plugin.yaml").write_text( "pip_dependencies:\n - definitely-not-installed-xyz\n", encoding="utf-8" ) - captured = {} + calls = [] def fake_run(cmd, **kw): - captured["cmd"] = cmd - return SimpleNamespace() + calls.append(cmd) + if run_behavior: + return run_behavior(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") with patch("plugins.memory.find_provider_dir", return_value=tmp_path), \ - patch("shutil.which", side_effect=which_side_effect), \ - patch("subprocess.run", fake_run): + patch("hermes_cli.tools_config.shutil.which", side_effect=which_side_effect), \ + patch("hermes_cli.tools_config.subprocess.run", fake_run): memory_setup._install_dependencies("x") - return captured.get("cmd"), sys.executable + return calls, sys.executable def test_uses_uv_when_available(self, tmp_path): - cmd, _ = self._run_with_missing_dep( + calls, _ = self._run_with_missing_dep( tmp_path, lambda b: "/usr/bin/uv" if b == "uv" else None ) - assert cmd is not None - assert cmd[:3] == ["/usr/bin/uv", "pip", "install"] + assert calls + assert calls[0][:3] == ["/usr/bin/uv", "pip", "install"] - def test_falls_back_to_pip_when_uv_missing(self, tmp_path, capsys): - """The salvaged behavior (#5954): no uv but pip present -> python -m pip.""" - cmd, py = self._run_with_missing_dep( - tmp_path, lambda b: "/usr/bin/pip3" if b == "pip3" else None - ) - assert cmd is not None - assert cmd[:4] == [py, "-m", "pip", "install"] - assert "Falling back to standard pip" in capsys.readouterr().out + def test_falls_back_to_pip_when_uv_missing(self, tmp_path): + """No uv but pip importable -> python -m pip install.""" + calls, py = self._run_with_missing_dep(tmp_path, lambda b: None) + assert calls + # Ladder probes pip first, then installs with it. + assert calls[0][:3] == [py, "-m", "pip"] + assert calls[-1][:4] == [py, "-m", "pip", "install"] - def test_aborts_when_neither_uv_nor_pip(self, tmp_path, capsys): - cmd, _ = self._run_with_missing_dep(tmp_path, lambda b: None) - assert cmd is None # no install attempted - assert "cannot install dependencies" in capsys.readouterr().out + def test_bootstraps_pip_via_ensurepip_when_missing(self, tmp_path): + """Neither uv nor pip -> ensurepip bootstrap, then pip install.""" + def behavior(cmd): + if cmd[-1] == "--version": + return SimpleNamespace(returncode=1, stdout="", stderr="") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + calls, py = self._run_with_missing_dep(tmp_path, lambda b: None, behavior) + assert any("ensurepip" in c for c in calls) + assert calls[-1][:4] == [py, "-m", "pip", "install"] From 9420f1acb64d709ee5f827c3730a49605878c2b2 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:33:47 -0700 Subject: [PATCH 092/610] test(google_meet): assert ladder-based dependency install instead of bespoke pip argv --- tests/plugins/test_google_meet_plugin.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/plugins/test_google_meet_plugin.py b/tests/plugins/test_google_meet_plugin.py index 92815553922d..028bf59efbe4 100644 --- a/tests/plugins/test_google_meet_plugin.py +++ b/tests/plugins/test_google_meet_plugin.py @@ -778,11 +778,13 @@ def test_cmd_install_runs_pip_and_playwright(capsys): patch("shutil.which", return_value="/usr/bin/paplay"): rc = _cmd_install(realtime=False, assume_yes=True) assert rc == 0 - # First invocation: pip install - pip_cmds = [c for c in calls if len(c) > 2 and c[1:4] == ["-m", "pip", "install"]] - assert pip_cmds, f"no pip install run: {calls}" - assert "playwright" in pip_cmds[0] - assert "websockets" in pip_cmds[0] + # First invocation: dependency install via the uv→pip ladder + # (shutil.which is mocked truthy, so the uv tier is taken: ` pip install ...`) + pip_cmds = [ + c for c in calls + if "install" in c and "playwright" in c and "websockets" in c + ] + assert pip_cmds, f"no dependency install run: {calls}" # Second: playwright install chromium pw_cmds = [c for c in calls if len(c) > 2 and c[1:4] == ["-m", "playwright", "install"]] assert pw_cmds, f"no playwright install run: {calls}" From b899ffd1ea846753b2fa9a28fe76b28191a45e90 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:28:12 -0700 Subject: [PATCH 093/610] test(e2e): stub reset-notice session info to deflake test_new_resets_session (#60175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /new's handler calls _reset_notice_session_info, which resolves live provider credentials and can probe model context length over HTTP. In CI there are no credentials, so resolution walks the entire fallback chain (the failed run's log shows 'Primary provider auth failed ... trying fallback' captured inside the test) and on a slow runner the first parametrization can blow past send_and_capture's 2s poll window, making adapter.send appear never-called. Stub it to return an empty info block in the e2e runner fixture — these tests exercise gateway command dispatch, not provider resolution, and no other network-touching path exists in the /new flow. Flaked in run 28856659216 (telegram param only); tests/e2e now 57/57 locally. --- tests/e2e/conftest.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 193f7f125d8a..46c98b5d0843 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -228,6 +228,13 @@ def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "Gate # Disable destructive slash confirm gate so /new executes immediately runner._read_user_config = lambda: {"approvals": {"destructive_slash_confirm": False}} + # Keep /new hermetic: the real _reset_notice_session_info resolves provider + # credentials and may probe model context length over the network. CI has no + # credentials, so resolution walks the whole fallback chain and can exceed + # send_and_capture's poll window on slow runners (flaked in run 28856659216, + # telegram param only — first parametrization pays the cold-resolution cost). + runner._reset_notice_session_info = lambda source: "" + runner.pairing_store = MagicMock() runner.pairing_store._is_rate_limited = MagicMock(return_value=False) runner.pairing_store.generate_code = MagicMock(return_value="ABC123") From 9c272a306eafd074b51520b5468dc816514e6d19 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:11:10 -0700 Subject: [PATCH 094/610] feat(gateway): default session auto-reset to off (mode: none) (#60194) Sessions no longer auto-reset by default. SessionResetPolicy.mode now defaults to "none" (was "both": 24h idle + daily 4am), matching the setup wizard's existing no-reset default and community feedback that surprise context loss hurts more than it helps. - gateway/config.py: dataclass default + from_dict fallback -> "none"; installs whose config.yaml lacks a session_reset section stop auto-resetting - hermes_cli/setup.py: "Never auto-reset" is now the recommended/default choice in hermes setup agent; stale comment updated - docs (en + zh-Hans): default is no auto-reset, opt in via session_reset in config.yaml Users who explicitly configured idle/daily/both resets keep them. --- gateway/config.py | 9 ++++++-- hermes_cli/setup.py | 14 ++++++------ tests/gateway/test_config.py | 4 ++-- website/docs/guides/tips.md | 2 +- website/docs/user-guide/messaging/index.md | 22 ++++++++++++++----- website/docs/user-guide/sessions.md | 5 +++-- .../current/guides/tips.md | 2 +- .../current/user-guide/messaging/index.md | 20 ++++++++++++----- .../current/user-guide/sessions.md | 4 ++-- 9 files changed, 53 insertions(+), 29 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index d6f84b2405ba..63b321d9b659 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -316,8 +316,13 @@ class SessionResetPolicy: - "idle": Reset after N minutes of inactivity - "both": Whichever triggers first (daily boundary OR idle timeout) - "none": Never auto-reset (context managed only by compression) + + Default is "none" — sessions never auto-reset unless the user opts in + via the `session_reset` section in config.yaml (or gateway.json + overrides). Changed July 2026 from "both" (24h idle + daily 4am), which + surprised users who expected their conversations to persist. """ - mode: str = "both" # "daily", "idle", "both", or "none" + mode: str = "none" # "daily", "idle", "both", or "none" at_hour: int = 4 # Hour for daily reset (0-23, local time) idle_minutes: int = 1440 # Minutes of inactivity before reset (24 hours) notify: bool = True # Send a notification to the user when auto-reset occurs @@ -349,7 +354,7 @@ class SessionResetPolicy: exclude = data.get("notify_exclude_platforms") bg_max_age = data.get("bg_process_max_age_hours") return cls( - mode=mode if mode is not None else "both", + mode=mode if mode is not None else "none", at_hour=at_hour if at_hour is not None else 4, idle_minutes=idle_minutes if idle_minutes is not None else 1440, notify=_coerce_bool(notify, True), diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index fcdba27165f6..54f4e5f676d5 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1463,9 +1463,9 @@ def _apply_default_agent_settings(config: dict): config.setdefault("compression", {})["enabled"] = True config["compression"]["threshold"] = 0.50 - # Default to never auto-resetting sessions. The gateway treats absent - # session_reset as "both", so we must write "none" explicitly to make - # the no-auto-reset default actually take effect. + # Default: never auto-reset sessions. This matches the gateway's own + # default (SessionResetPolicy.mode = "none"); we still write it + # explicitly so the choice is visible/editable in config.yaml. config.setdefault("session_reset", {})["mode"] = "none" save_config(config) @@ -1576,19 +1576,19 @@ def setup_agent_settings(config: dict): print_info("") reset_choices = [ - "Inactivity + daily reset (recommended - reset whichever comes first)", + "Inactivity + daily reset (reset whichever comes first)", "Inactivity only (reset after N minutes of no messages)", "Daily only (reset at a fixed hour each day)", - "Never auto-reset (context lives until /reset or context compression)", + "Never auto-reset (recommended - context lives until /reset or context compression)", "Keep current settings", ] current_policy = config.get("session_reset", {}) - current_mode = current_policy.get("mode", "both") + current_mode = current_policy.get("mode", "none") current_idle = current_policy.get("idle_minutes", 1440) current_hour = current_policy.get("at_hour", 4) - default_reset = {"both": 0, "idle": 1, "daily": 2, "none": 3}.get(current_mode, 0) + default_reset = {"both": 0, "idle": 1, "daily": 2, "none": 3}.get(current_mode, 3) reset_idx = prompt_choice("Session reset mode:", reset_choices, default_reset) diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index d32c63ee8be9..b9d4993b5f06 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -219,7 +219,7 @@ class TestSessionResetPolicy: def test_defaults(self): policy = SessionResetPolicy() - assert policy.mode == "both" + assert policy.mode == "none" assert policy.at_hour == 4 assert policy.idle_minutes == 1440 assert policy.bg_process_max_age_hours == 24 @@ -229,7 +229,7 @@ class TestSessionResetPolicy: {"mode": None, "at_hour": None, "idle_minutes": None, "bg_process_max_age_hours": None} ) - assert restored.mode == "both" + assert restored.mode == "none" assert restored.at_hour == 4 assert restored.idle_minutes == 1440 assert restored.bg_process_max_age_hours == 24 diff --git a/website/docs/guides/tips.md b/website/docs/guides/tips.md index eed93187b4a9..fd4501080a0d 100644 --- a/website/docs/guides/tips.md +++ b/website/docs/guides/tips.md @@ -174,7 +174,7 @@ Instead of manually collecting user IDs for allowlists, enable DM pairing. When Use `/verbose` to control how much tool activity you see. In messaging platforms, less is usually more — keep it on "new" to see just new tool calls. In the CLI, "all" gives you a satisfying live view of everything the agent does. :::tip -On messaging platforms, sessions auto-reset after idle time (default: 24 hours) or daily at 4 AM. Adjust per-platform in `~/.hermes/config.yaml` if you need longer sessions. +By default, messaging sessions never auto-reset — context lives until you `/reset` or compression kicks in. If you want sessions to reset automatically (after idle time or daily at a fixed hour), opt in via the `session_reset` section in `~/.hermes/config.yaml`. ::: ## Security diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index 0e9327f27028..8011191c89ff 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -191,13 +191,23 @@ Sessions persist across messages until they reset. The agent remembers your conv ### Reset Policies -Sessions reset based on configurable policies: +**By default sessions never auto-reset** — context lives until you `/reset` +manually or context compression kicks in. If you want automatic resets, opt in +with the `session_reset` section in `~/.hermes/config.yaml`: -| Policy | Default | Description | -|--------|---------|-------------| -| Daily | 4:00 AM | Reset at a specific hour each day | -| Idle | 1440 min | Reset after N minutes of inactivity | -| Both | (combined) | Whichever triggers first | +```yaml +session_reset: + mode: idle # "idle", "daily", "both", or "none" (default) + idle_minutes: 1440 # for idle/both: minutes of inactivity before reset + at_hour: 4 # for daily/both: hour of day (0-23, local time) +``` + +| Mode | Description | +|------|-------------| +| `none` | Never auto-reset (default) | +| `daily` | Reset at a specific hour each day | +| `idle` | Reset after N minutes of inactivity | +| `both` | Whichever triggers first | A live background process (started with `terminal(background=true)`) normally protects its session from resetting so output isn't lost. To stop a forgotten diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index 2e1ec54640ff..e4626357be42 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -541,12 +541,13 @@ That reverts groups/channels to a single shared session per room, which preserve ### Session Reset Policies -Gateway sessions are automatically reset based on configurable policies: +**By default gateway sessions never auto-reset** (`mode: none`). You can opt +in to automatic resets via the `session_reset` section in `config.yaml`: +- **none** — never auto-reset (default; context managed by `/reset` and compression) - **idle** — reset after N minutes of inactivity - **daily** — reset at a specific hour each day - **both** — reset on whichever comes first (idle or daily) -- **none** — never auto-reset Before a session is auto-reset, the agent is given a turn to save any important memories or skills from the conversation. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/tips.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/tips.md index adc7a1baa049..140a294ecf86 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/tips.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/tips.md @@ -170,7 +170,7 @@ Hermes 在会话启动时从当前工作目录加载顶层 `AGENTS.md`。子目 使用 `/verbose` 控制工具活动的显示详细程度。在消息平台上,通常越简洁越好——保持"new"模式只查看新的工具调用。在 CLI 中,"all" 模式可以实时查看 agent 的所有操作。 :::tip -在消息平台上,会话会在空闲一段时间后自动重置(默认 24 小时),或每天凌晨 4 点重置。如需更长的会话时间,可在 `~/.hermes/config.yaml` 中按平台调整。 +默认情况下,消息平台的会话永不自动重置 —— 上下文会一直保留,直到你手动 `/reset` 或触发上下文压缩。如需自动重置(空闲超时或每天固定时间),可在 `~/.hermes/config.yaml` 的 `session_reset` 部分选择启用。 ::: ## 安全 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/index.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/index.md index f25bb10ed03e..446d42e92a27 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/index.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/index.md @@ -162,13 +162,21 @@ hermes gateway status --system # 仅 Linux:显式检查系统服务 ### 重置策略 -会话根据可配置的策略重置: +**默认情况下会话永不自动重置** —— 上下文会一直保留,直到你手动 `/reset` 或触发上下文压缩。如果你希望会话自动重置,可在 `~/.hermes/config.yaml` 的 `session_reset` 部分选择启用: -| 策略 | 默认值 | 说明 | -|--------|---------|-------------| -| 每日 | 凌晨 4:00 | 每天在指定时间重置 | -| 空闲 | 1440 分钟 | 空闲 N 分钟后重置 | -| 两者 | (组合) | 以先触发者为准 | +```yaml +session_reset: + mode: idle # "idle"、"daily"、"both" 或 "none"(默认) + idle_minutes: 1440 # idle/both 模式:空闲多少分钟后重置 + at_hour: 4 # daily/both 模式:每天的重置时间(0-23,本地时间) +``` + +| 模式 | 说明 | +|------|-------------| +| `none` | 永不自动重置(默认) | +| `daily` | 每天在指定时间重置 | +| `idle` | 空闲 N 分钟后重置 | +| `both` | 以先触发者为准 | 在 `~/.hermes/gateway.json` 中配置各平台的覆盖设置: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md index e2096c71f514..60a6ab78f69f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md @@ -450,12 +450,12 @@ group_sessions_per_user: false ### Session 重置策略 -Gateway session 根据可配置的策略自动重置: +**默认情况下 Gateway session 永不自动重置**(`mode: none`)。你可以通过 `config.yaml` 中的 `session_reset` 部分选择启用自动重置: +- **none** — 永不自动重置(默认;上下文由 `/reset` 和压缩管理) - **idle** — 在 N 分钟不活跃后重置 - **daily** — 每天在特定时间重置 - **both** — 以先到者为准(idle 或 daily) -- **none** — 永不自动重置 在 session 自动重置之前,agent 会有一轮机会保存对话中的重要记忆或技能。 From 0d9ed9214d3682f4d274b8d8e2455261217ebce5 Mon Sep 17 00:00:00 2001 From: Georgio Constantinou <210088133+rungmc357@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:20:47 -0400 Subject: [PATCH 095/610] Add semantic titles for Discord auto-threads --- gateway/platforms/base.py | 4 + gateway/run.py | 86 +++++++++++++++ gateway/session.py | 14 +++ plugins/platforms/discord/adapter.py | 105 ++++++++++++++++--- tests/gateway/test_discord_slash_commands.py | 71 +++++++++++++ tests/gateway/test_fast_command.py | 69 +++++++++++- 6 files changed, 335 insertions(+), 14 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 1025964dc43b..21ca4af6bb4d 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -5445,6 +5445,8 @@ class BasePlatformAdapter(ABC): parent_chat_id: Optional[str] = None, message_id: Optional[str] = None, role_authorized: bool = False, + auto_thread_created: bool = False, + auto_thread_initial_name: Optional[str] = None, ) -> SessionSource: """Helper to build a SessionSource for this platform.""" # Normalize empty topic to None @@ -5466,6 +5468,8 @@ class BasePlatformAdapter(ABC): parent_chat_id=str(parent_chat_id) if parent_chat_id else None, message_id=str(message_id) if message_id else None, role_authorized=role_authorized, + auto_thread_created=auto_thread_created, + auto_thread_initial_name=auto_thread_initial_name, ) @abstractmethod diff --git a/gateway/run.py b/gateway/run.py index 6dd6fbdf23ae..3102f8834306 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -13317,6 +13317,86 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew cleaned = cleaned[:117].rstrip() + "..." return cleaned + def _is_discord_auto_thread_lane(self, source: SessionSource) -> bool: + """Return True only for Discord threads Hermes just auto-created.""" + return ( + source.platform == Platform.DISCORD + and source.chat_type == "thread" + and bool(getattr(source, "auto_thread_created", False)) + and bool(source.thread_id) + and bool(getattr(source, "auto_thread_initial_name", None)) + ) + + def _sanitize_discord_thread_title(self, title: str) -> str: + """Return a Discord-safe semantic thread title from a session title.""" + cleaned = re.sub(r"\s+", " ", str(title or "")).strip() + if not cleaned: + return "Hermes Chat" + if len(cleaned) > 80: + cleaned = cleaned[:77].rstrip() + "..." + return cleaned + + async def _rename_discord_auto_thread_for_session_title( + self, + source: SessionSource, + session_id: str, + title: str, + ) -> None: + """Best-effort semantic rename of a newly auto-created Discord thread.""" + if not await asyncio.to_thread(self._is_discord_auto_thread_lane, source): + return + adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + if adapter is None: + return + rename_thread = getattr(adapter, "rename_thread", None) + if rename_thread is None: + return + thread_name = self._sanitize_discord_thread_title(title) + try: + await rename_thread( + str(source.thread_id), + thread_name, + only_if_current_name=getattr(source, "auto_thread_initial_name", None), + ) + except Exception: + logger.debug("Failed to rename Discord auto-thread for generated session title", exc_info=True) + + def _schedule_discord_semantic_thread_rename( + self, + source: SessionSource, + session_id: str, + title: str, + ) -> None: + """Schedule Discord auto-thread rename from the auto-title background thread.""" + if not title or not self._is_discord_auto_thread_lane(source): + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = getattr(self, "_gateway_loop", None) + if loop is None or loop.is_closed(): + return + try: + copied_source = dataclasses.replace(source) + except Exception: + copied_source = source + future = safe_schedule_threadsafe( + self._rename_discord_auto_thread_for_session_title(copied_source, session_id, title), + loop, + logger=logger, + log_message="Discord semantic thread rename failed to schedule", + ) + if future is None: + return + + def _log_rename_failure(fut) -> None: + try: + fut.result() + except Exception: + logger.debug("Discord semantic thread rename failed", exc_info=True) + + future.add_done_callback(_log_rename_failure) + async def _rename_telegram_topic_for_session_title( self, source: SessionSource, @@ -18700,6 +18780,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew effective_session_id, title, ) + elif self._is_discord_auto_thread_lane(source): + maybe_auto_title_kwargs["title_callback"] = lambda title: self._schedule_discord_semantic_thread_rename( + source, + effective_session_id, + title, + ) maybe_auto_title( getattr(self._session_db, "_db", self._session_db), effective_session_id, diff --git a/gateway/session.py b/gateway/session.py index c0f631ffd2b5..fb2e08f4299e 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -181,6 +181,14 @@ class SessionSource: # namespacing and the per-turn config/credential scope. profile: Optional[str] = None + # Discord auto-thread metadata. Newly auto-created Discord threads start + # with a fast placeholder title from the raw message, then the gateway can + # rename them after the first agent turn using the generated session title. + # Keep this explicit so pre-existing or human-renamed threads are not + # mistaken for safe rename targets. + auto_thread_created: bool = False + auto_thread_initial_name: Optional[str] = None + # Internal, wire-INVISIBLE trust signal: True when this event was delivered # to the gateway over the per-instance-authenticated relay WebSocket (the # Team Gateway connector). The connector authenticates the gateway's socket @@ -254,6 +262,10 @@ class SessionSource: d["message_id"] = self.message_id if self.profile: d["profile"] = self.profile + if self.auto_thread_created: + d["auto_thread_created"] = True + if self.auto_thread_initial_name: + d["auto_thread_initial_name"] = self.auto_thread_initial_name return d @classmethod @@ -275,6 +287,8 @@ class SessionSource: parent_chat_id=data.get("parent_chat_id"), message_id=data.get("message_id"), profile=data.get("profile"), + auto_thread_created=bool(data.get("auto_thread_created", False)), + auto_thread_initial_name=data.get("auto_thread_initial_name"), ) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 1698f65d6c16..755ad69a8fee 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -5171,6 +5171,26 @@ class DiscordAdapter(BasePlatformAdapter): # Auto-thread helpers # ------------------------------------------------------------------ + def _derive_auto_thread_name(self, content: str) -> str: + """Return the fast placeholder name used at Discord thread creation time. + + Strip Discord mention syntax (users / roles / channels) so thread + titles don't show raw <@id>, <@&id>, or <#id> markers — the ID + isn't meaningful to humans glancing at the thread list (#6336). + Real semantic naming is done after the first agent turn, when + Hermes has an LLM-generated session title and can safely rename + only this newly-created thread. + """ + content = (content or "").strip() + # <@123>, <@!123>, <@&123>, <#123> — collapse to empty; normalize spaces. + content = re.sub(r"<@[!&]?\d+>", "", content) + content = re.sub(r"<#\d+>", "", content) + content = re.sub(r"\s+", " ", content).strip() + thread_name = content[:80] if content else "Hermes" + if len(content) > 80: + thread_name = thread_name[:77] + "..." + return thread_name + async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]: """Create a thread from a user message for auto-threading. @@ -5180,19 +5200,7 @@ class DiscordAdapter(BasePlatformAdapter): (e.g. ``Cannot connect to host discord.com:443``) don't immediately burn through to the caller's failure path (#20243). """ - # Build a short thread name from the message. Strip Discord mention - # syntax (users / roles / channels) so thread titles don't end up - # showing raw <@id>, <@&id>, or <#id> markers — the ID isn't - # meaningful to humans glancing at the thread list (#6336). - content = (message.content or "").strip() - # <@123>, <@!123>, <@&123>, <#123> — collapse to empty; normalize spaces. - content = re.sub(r"<@[!&]?\d+>", "", content) - content = re.sub(r"<#\d+>", "", content) - content = re.sub(r"\s+", " ", content).strip() - thread_name = content[:80] if content else "Hermes" - if len(content) > 80: - thread_name = thread_name[:77] + "..." - + thread_name = self._derive_auto_thread_name(message.content or "") display_name = getattr(getattr(message, "author", None), "display_name", None) or "unknown user" reason = f"Auto-threaded from mention by {display_name}" @@ -5202,6 +5210,10 @@ class DiscordAdapter(BasePlatformAdapter): for attempt in range(2): try: thread = await message.create_thread(name=thread_name, auto_archive_duration=1440) + try: + setattr(thread, "_hermes_auto_thread_initial_name", thread_name) + except Exception: + pass return thread except Exception as direct_error: last_direct_error = direct_error @@ -5214,6 +5226,10 @@ class DiscordAdapter(BasePlatformAdapter): auto_archive_duration=1440, reason=reason, ) + try: + setattr(thread, "_hermes_auto_thread_initial_name", thread_name) + except Exception: + pass return thread except Exception as fallback_error: last_fallback_error = fallback_error @@ -5232,6 +5248,64 @@ class DiscordAdapter(BasePlatformAdapter): ) return None + async def rename_thread( + self, + thread_id: str, + name: str, + *, + only_if_current_name: Optional[str] = None, + ) -> bool: + """Best-effort Discord thread rename. + + ``only_if_current_name`` prevents overwriting human-renamed or + pre-existing threads. This is intentionally a no-op on mismatch. + """ + if not self._client or not DISCORD_AVAILABLE: + return False + + try: + thread_id_int = int(str(thread_id)) + except (TypeError, ValueError): + return False + + cleaned = re.sub(r"\s+", " ", str(name or "")).strip() + if not cleaned: + return False + if len(cleaned) > 80: + cleaned = cleaned[:77].rstrip() + "..." + + try: + thread = self._client.get_channel(thread_id_int) + if thread is None: + thread = await self._client.fetch_channel(thread_id_int) + except Exception: + logger.debug("[%s] Failed to resolve Discord thread %s for rename", self.name, thread_id, exc_info=True) + return False + + current_name = getattr(thread, "name", None) + if only_if_current_name is not None and current_name != only_if_current_name: + logger.info( + "[%s] Discord semantic thread rename skipped for %s: current name %r != expected %r", + self.name, thread_id, current_name, only_if_current_name, + ) + return False + if current_name == cleaned: + return True + + edit = getattr(thread, "edit", None) + if edit is None: + return False + try: + await edit(name=cleaned, reason="Hermes semantic session title") + logger.info( + "[%s] Renamed Discord thread %s from %r to %r", + self.name, thread_id, current_name, cleaned, + ) + return True + except Exception: + logger.debug("[%s] Failed to rename Discord thread %s", self.name, thread_id, exc_info=True) + return False + async def create_handoff_thread( self, parent_chat_id: str, @@ -6015,6 +6089,11 @@ class DiscordAdapter(BasePlatformAdapter): parent_chat_id=parent_channel_id, message_id=str(message.id), role_authorized=role_authorized, + auto_thread_created=auto_threaded_channel is not None, + auto_thread_initial_name=( + getattr(auto_threaded_channel, "_hermes_auto_thread_initial_name", None) + or self._derive_auto_thread_name(message.content or "") + ) if auto_threaded_channel is not None else None, ) # Build media URLs -- download image attachments to local cache so the diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index 5ef6812f5375..19710626ca69 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -562,6 +562,7 @@ async def test_auto_create_thread_uses_message_content_as_name(adapter): call_kwargs = message.create_thread.await_args[1] assert call_kwargs["name"] == "Hello world, how are you?" assert call_kwargs["auto_archive_duration"] == 1440 + assert thread._hermes_auto_thread_initial_name == "Hello world, how are you?" @pytest.mark.asyncio @@ -659,6 +660,47 @@ async def test_auto_create_thread_returns_none_when_direct_and_fallback_fail(ada assert result is None +@pytest.mark.asyncio +async def test_rename_thread_edits_only_when_current_name_matches(adapter): + thread = SimpleNamespace( + id=999, + name="raw user prompt", + edit=AsyncMock(), + ) + adapter._client.get_channel = lambda _id: thread + + result = await adapter.rename_thread( + "999", + "Semantic Session Title", + only_if_current_name="raw user prompt", + ) + + assert result is True + thread.edit.assert_awaited_once_with( + name="Semantic Session Title", + reason="Hermes semantic session title", + ) + + +@pytest.mark.asyncio +async def test_rename_thread_skips_when_human_renamed(adapter): + thread = SimpleNamespace( + id=999, + name="human fixed this already", + edit=AsyncMock(), + ) + adapter._client.get_channel = lambda _id: thread + + result = await adapter.rename_thread( + "999", + "Semantic Session Title", + only_if_current_name="raw user prompt", + ) + + assert result is False + thread.edit.assert_not_awaited() + + # ------------------------------------------------------------------ # Auto-thread integration in _handle_message # ------------------------------------------------------------------ @@ -742,6 +784,35 @@ async def test_auto_thread_creates_thread_and_redirects(adapter, monkeypatch): assert event.source.chat_id == "999" # redirected to thread assert event.source.chat_type == "thread" assert event.source.thread_id == "999" + assert event.source.auto_thread_created is True + + +@pytest.mark.asyncio +async def test_auto_thread_source_carries_initial_name_for_semantic_rename(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_AUTO_THREAD", "true") + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") + + thread = SimpleNamespace( + id=999, + name="raw user prompt", + _hermes_auto_thread_initial_name="raw user prompt", + ) + adapter._auto_create_thread = AsyncMock(return_value=thread) + + captured_events = [] + + async def capture_handle(event): + captured_events.append(event) + + adapter.handle_message = capture_handle + + msg = _fake_message(_FakeTextChannel(), content="raw user prompt") + + await adapter._handle_message(msg) + + source = captured_events[0].source + assert source.auto_thread_created is True + assert source.auto_thread_initial_name == "raw user prompt" @pytest.mark.asyncio diff --git a/tests/gateway/test_fast_command.py b/tests/gateway/test_fast_command.py index c10000940070..a5b07c89880d 100644 --- a/tests/gateway/test_fast_command.py +++ b/tests/gateway/test_fast_command.py @@ -4,7 +4,7 @@ import sys import threading import types from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest import yaml @@ -87,6 +87,19 @@ def _make_source() -> SessionSource: ) +def _make_discord_auto_thread_source() -> SessionSource: + return SessionSource( + platform=Platform.DISCORD, + chat_id="999", + chat_type="thread", + user_id="user-1", + thread_id="999", + parent_chat_id="100", + auto_thread_created=True, + auto_thread_initial_name="raw user prompt", + ) + + def _make_event(text: str) -> MessageEvent: return MessageEvent(text=text, source=_make_source(), message_id="m1") @@ -193,3 +206,57 @@ async def test_run_agent_passes_priority_processing_to_gateway_agent(monkeypatch assert result["final_response"] == "ok" assert _CapturingAgent.last_init["service_tier"] == "priority" assert _CapturingAgent.last_init["request_overrides"] == {"service_tier": "priority"} + + +@pytest.mark.asyncio +async def test_run_agent_passes_discord_auto_thread_title_callback(monkeypatch, tmp_path): + _install_fake_agent(monkeypatch) + runner = _make_runner() + runner._session_db = SimpleNamespace(_db=MagicMock()) # type: ignore[assignment] + + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr(gateway_run, "_env_path", tmp_path / ".env") + monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) + monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: {}) + monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4") + monkeypatch.setattr( + gateway_run, + "_resolve_runtime_agent_kwargs", + lambda: { + "provider": "openrouter", + "api_mode": "chat_completions", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "***", + }, + ) + + import hermes_cli.tools_config as tools_config + monkeypatch.setattr(tools_config, "_get_platform_tools", lambda user_config, platform_key: {"core"}) + + with patch("agent.title_generator.maybe_auto_title") as mock_title: + await runner._run_agent( + message="raw user prompt", + context_prompt="", + history=[], + source=_make_discord_auto_thread_source(), + session_id="session-1", + session_key="agent:main:discord:thread:999", + ) + + mock_title.assert_called_once() + callback = mock_title.call_args.kwargs["title_callback"] + with patch.object(runner, "_schedule_discord_semantic_thread_rename") as mock_schedule: + callback("Semantic Session Title") + mock_schedule.assert_called_once() + assert mock_schedule.call_args.args[1] == "session-1" + assert mock_schedule.call_args.args[2] == "Semantic Session Title" + + +def test_session_source_preserves_discord_auto_thread_metadata(): + source = _make_discord_auto_thread_source() + + restored = SessionSource.from_dict(source.to_dict()) + + assert restored.auto_thread_created is True + assert restored.auto_thread_initial_name == "raw user prompt" From 1deeaf71abcf84d9f5d4d8255abfd5654a0ed2e1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:30:11 -0700 Subject: [PATCH 096/610] fix(discord): truncate thread titles by UTF-16 units + AUTHOR_MAP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord thread names share the same UTF-16 component budget as select labels and buttons — route the sanitizers in gateway/run.py and the adapter's rename_thread through utf16_len/_prefix_within_utf16_limit instead of code-point slices. Adds rungmc357 to AUTHOR_MAP. --- gateway/run.py | 13 ++++++++++--- plugins/platforms/discord/adapter.py | 7 +++++-- scripts/release.py | 1 + 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 3102f8834306..af7f5c7b46c9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1761,8 +1761,10 @@ from gateway.platforms.base import ( EphemeralReply, MessageEvent, MessageType, + _prefix_within_utf16_limit, _reply_anchor_for_event, merge_pending_message_event, + utf16_len, ) from gateway.restart import ( DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT, @@ -13328,12 +13330,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) def _sanitize_discord_thread_title(self, title: str) -> str: - """Return a Discord-safe semantic thread title from a session title.""" + """Return a Discord-safe semantic thread title from a session title. + + Discord thread names are capped at 100 characters measured in UTF-16 + code units (emoji count double), so truncate with the UTF-16 helpers + rather than Python code-point slices. + """ cleaned = re.sub(r"\s+", " ", str(title or "")).strip() if not cleaned: return "Hermes Chat" - if len(cleaned) > 80: - cleaned = cleaned[:77].rstrip() + "..." + if utf16_len(cleaned) > 80: + cleaned = _prefix_within_utf16_limit(cleaned, 77).rstrip() + "..." return cleaned async def _rename_discord_auto_thread_for_session_title( diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 755ad69a8fee..641215bd705d 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -5271,8 +5271,11 @@ class DiscordAdapter(BasePlatformAdapter): cleaned = re.sub(r"\s+", " ", str(name or "")).strip() if not cleaned: return False - if len(cleaned) > 80: - cleaned = cleaned[:77].rstrip() + "..." + # Discord thread names are budgeted in UTF-16 code units (emoji count + # double) — truncate with the UTF-16 helpers, not code-point slices. + from gateway.platforms.base import utf16_len, _prefix_within_utf16_limit + if utf16_len(cleaned) > 80: + cleaned = _prefix_within_utf16_limit(cleaned, 77).rstrip() + "..." try: thread = self._client.get_channel(thread_id_int) diff --git a/scripts/release.py b/scripts/release.py index a1a699e5e39c..9127dc38410b 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -290,6 +290,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "210088133+rungmc357@users.noreply.github.com": "rungmc357", "florian.rutishauser@outlook.com": "flo1t", "fanyang@microsoft.com": "fanyangCS", "bigstar0920@gmail.com": "bigstar0920", From 491689784e6d072d22ee5c2b491798b99ba3eb92 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:12:08 -0700 Subject: [PATCH 097/610] feat: add uninstall dry-run mode Port from qwibitai/nanoclaw#2719: let operators preview the uninstall plan without stopping services or deleting files. --- hermes_cli/subcommands/uninstall.py | 5 +++ hermes_cli/uninstall.py | 32 +++++++++++++++ tests/hermes_cli/test_uninstall_dry_run.py | 48 ++++++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 tests/hermes_cli/test_uninstall_dry_run.py diff --git a/hermes_cli/subcommands/uninstall.py b/hermes_cli/subcommands/uninstall.py index 1250af3e04d7..f746a8906a64 100644 --- a/hermes_cli/subcommands/uninstall.py +++ b/hermes_cli/subcommands/uninstall.py @@ -38,4 +38,9 @@ def build_uninstall_parser(subparsers, *, cmd_uninstall: Callable) -> None: uninstall_parser.add_argument( "--yes", "-y", action="store_true", help="Skip confirmation prompts" ) + uninstall_parser.add_argument( + "--dry-run", + action="store_true", + help="Print what uninstall would remove without changing anything", + ) uninstall_parser.set_defaults(func=cmd_uninstall) diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index 9b53500734be..6832d3d56074 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -575,6 +575,14 @@ def run_uninstall(args): project_root = get_project_root() hermes_home = get_hermes_home() + if bool(getattr(args, "dry_run", False)): + _print_uninstall_dry_run( + project_root=project_root, + hermes_home=hermes_home, + full_uninstall=bool(getattr(args, "full", False)), + ) + return + # Detect named profiles when uninstalling from the default root — # offer to clean them up too instead of leaving zombie HERMES_HOMEs # and systemd units behind. @@ -704,6 +712,30 @@ def run_uninstall(args): ) +def _print_uninstall_dry_run(*, project_root: Path, hermes_home: Path, full_uninstall: bool) -> None: + """Print the uninstall plan without stopping services or deleting files.""" + print() + print(color("Dry run: no files, services, or environment entries will be changed.", Colors.CYAN, Colors.BOLD)) + print() + print(color("Would inspect/remove:", Colors.YELLOW, Colors.BOLD)) + print(" • Gateway services and standalone gateway processes") + print(" • Hermes PATH entries from shell configs / Windows User PATH") + print(" • Hermes wrapper scripts and Hermes-managed node/npm/npx symlinks") + print(" • Desktop Chat GUI artifacts") + print(f" • Code checkout: {project_root}") + if full_uninstall: + print(f" • Hermes config/data: {hermes_home}") + if _is_default_hermes_home(hermes_home): + profiles = _discover_named_profiles() + if profiles: + print(" • Named profiles (interactive uninstall asks before removing):") + for prof in profiles: + print(f" - {prof.name}: {prof.path}") + else: + print(f" • Keep Hermes config/data: {hermes_home}") + print() + + def _perform_uninstall( *, project_root: Path, diff --git a/tests/hermes_cli/test_uninstall_dry_run.py b/tests/hermes_cli/test_uninstall_dry_run.py new file mode 100644 index 000000000000..ec74c8e5c18c --- /dev/null +++ b/tests/hermes_cli/test_uninstall_dry_run.py @@ -0,0 +1,48 @@ +from pathlib import Path +from types import SimpleNamespace + +from hermes_cli import uninstall + + +def test_dry_run_prints_plan_without_mutating(monkeypatch, tmp_path, capsys): + project_root = tmp_path / "hermes-agent" + hermes_home = tmp_path / ".hermes" + project_root.mkdir() + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text("model: {}\n") + + called = False + + def _fail_if_called(**kwargs): + nonlocal called + called = True + + monkeypatch.setattr(uninstall, "get_project_root", lambda: project_root) + monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home) + monkeypatch.setattr(uninstall, "_is_default_hermes_home", lambda home: False) + monkeypatch.setattr(uninstall, "_discover_named_profiles", lambda: []) + monkeypatch.setattr(uninstall, "_perform_uninstall", _fail_if_called) + + uninstall.run_uninstall(SimpleNamespace(dry_run=True, yes=True, full=True)) + + output = capsys.readouterr().out + assert called is False + assert "Dry run" in output + assert str(project_root) in output + assert str(hermes_home) in output + assert project_root.exists() + assert hermes_home.exists() + + +def test_build_uninstall_parser_accepts_dry_run(): + import argparse + from hermes_cli.subcommands.uninstall import build_uninstall_parser + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command") + build_uninstall_parser(subparsers, cmd_uninstall=lambda args: args) + + args = parser.parse_args(["uninstall", "--dry-run", "--full"]) + + assert args.dry_run is True + assert args.full is True From f8723c47818e84df7d4514228381be5b0509b7f3 Mon Sep 17 00:00:00 2001 From: JP Lew <462836+jplew@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:55:16 +0000 Subject: [PATCH 098/610] fix(skills): resolve skills dir from active profile --- tests/tools/test_skills_tool_profile_scope.py | 105 ++++++++++++++++++ tools/skills_tool.py | 39 +++++-- 2 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 tests/tools/test_skills_tool_profile_scope.py diff --git a/tests/tools/test_skills_tool_profile_scope.py b/tests/tools/test_skills_tool_profile_scope.py new file mode 100644 index 000000000000..4c8b4cd8a4d0 --- /dev/null +++ b/tests/tools/test_skills_tool_profile_scope.py @@ -0,0 +1,105 @@ +"""Regression tests for profile-scoped skills_tool path resolution.""" + +import importlib +import json +from pathlib import Path + + +def _write_skill(root: Path, category: str, name: str, description: str) -> Path: + skill_dir = root / "skills" / category / name + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + f"---\n" + f"name: {name}\n" + f"description: {description}\n" + f"---\n\n" + f"# {name}\n\n" + f"Loaded from {description}.\n", + encoding="utf-8", + ) + return skill_dir + + +def _reload_skills_tool(import_home: Path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(import_home)) + import tools.skills_tool as skills_tool + + return importlib.reload(skills_tool) + + +def test_skill_view_uses_live_profile_home_after_module_import(tmp_path, monkeypatch): + """skill_view should not stay pinned to HERMES_HOME from import time.""" + default_home = tmp_path / "default-home" + profile_home = tmp_path / "profiles" / "orchestrator" + _write_skill(default_home, "autonomous-ai-agents", "default-only", "default home") + profile_skill_dir = _write_skill( + profile_home, + "software-development", + "kanban-orchestrator-operations", + "orchestrator profile", + ) + + skills_tool = _reload_skills_tool(default_home, monkeypatch) + assert skills_tool.SKILLS_DIR == default_home / "skills" + + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + + result = json.loads( + skills_tool.skill_view("kanban-orchestrator-operations", preprocess=False) + ) + + assert result["success"] is True + assert result["name"] == "kanban-orchestrator-operations" + assert Path(result["skill_dir"]) == profile_skill_dir + assert "orchestrator profile" in result["content"] + + +def test_skills_list_uses_live_profile_home_after_module_import(tmp_path, monkeypatch): + """skills_list should list the active profile skills, not the import-time root.""" + default_home = tmp_path / "default-home" + profile_home = tmp_path / "profiles" / "orchestrator" + _write_skill(default_home, "autonomous-ai-agents", "default-only", "default home") + _write_skill( + profile_home, + "software-development", + "kanban-orchestrator-operations", + "orchestrator profile", + ) + + skills_tool = _reload_skills_tool(default_home, monkeypatch) + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + + result = json.loads(skills_tool.skills_list()) + names = {skill["name"] for skill in result["skills"]} + + assert result["success"] is True + assert "kanban-orchestrator-operations" in names + assert "default-only" not in names + + +def test_explicit_skills_dir_monkeypatch_still_wins(tmp_path, monkeypatch): + """Existing tests can still override tools.skills_tool.SKILLS_DIR directly.""" + default_home = tmp_path / "default-home" + profile_home = tmp_path / "profiles" / "orchestrator" + patched_root = tmp_path / "patched" + patched_skill_dir = _write_skill( + patched_root, + "software-development", + "patched-skill", + "patched skills dir", + ) + _write_skill( + profile_home, + "software-development", + "profile-skill", + "orchestrator profile", + ) + + skills_tool = _reload_skills_tool(default_home, monkeypatch) + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + monkeypatch.setattr(skills_tool, "SKILLS_DIR", patched_root / "skills") + + result = json.loads(skills_tool.skill_view("patched-skill", preprocess=False)) + + assert result["success"] is True + assert Path(result["skill_dir"]) == patched_skill_dir diff --git a/tools/skills_tool.py b/tools/skills_tool.py index cd699fae8f6a..86a8a5de6cb7 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -92,6 +92,22 @@ logger = logging.getLogger(__name__) # skills all coexist here without polluting the git repo. HERMES_HOME = get_hermes_home() SKILLS_DIR = HERMES_HOME / "skills" +_SKILLS_DIR_AT_IMPORT = SKILLS_DIR + + +def _skills_dir() -> Path: + """Return the active profile's skills directory at call time. + + Some long-lived runtimes import this module before the active profile has + set HERMES_HOME. Keep the legacy SKILLS_DIR module attribute for tests and + external patchers, but when it has not been patched, resolve from the live + profile-scoped HERMES_HOME on every call. + """ + configured = Path(SKILLS_DIR) + if configured != _SKILLS_DIR_AT_IMPORT: + return configured + return get_hermes_home() / "skills" + # Anthropic-recommended limits for progressive disclosure efficiency MAX_NAME_LENGTH = 64 @@ -501,9 +517,9 @@ def _get_category_from_path(skill_path: Path) -> Optional[str]: For paths like: ~/.hermes/skills/mlops/axolotl/SKILL.md -> "mlops" Also works for external skill dirs configured via skills.external_dirs. """ - # Try the module-level SKILLS_DIR first (respects monkeypatching in tests), + # Try the active profile skills dir first (respects monkeypatching in tests), # then fall back to external dirs from config. - dirs_to_check = [SKILLS_DIR] + dirs_to_check = [_skills_dir()] try: from agent.skill_utils import get_external_skills_dirs dirs_to_check.extend(get_external_skills_dirs()) @@ -622,8 +638,9 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: # Scan local dir first, then external dirs (local takes precedence) dirs_to_scan = [] - if SKILLS_DIR.exists(): - dirs_to_scan.append(SKILLS_DIR) + active_skills_dir = _skills_dir() + if active_skills_dir.exists(): + dirs_to_scan.append(active_skills_dir) dirs_to_scan.extend(get_external_skills_dirs()) for scan_dir in dirs_to_scan: @@ -701,8 +718,9 @@ def skills_list(category: str = None, task_id: str = None) -> str: JSON string with minimal skill info: name, description, category """ try: - if not SKILLS_DIR.exists(): - SKILLS_DIR.mkdir(parents=True, exist_ok=True) + active_skills_dir = _skills_dir() + if not active_skills_dir.exists(): + active_skills_dir.mkdir(parents=True, exist_ok=True) return json.dumps( { "success": True, @@ -984,8 +1002,9 @@ def skill_view( # Build list of all skill directories to search all_dirs = [] - if SKILLS_DIR.exists(): - all_dirs.append(SKILLS_DIR) + active_skills_dir = _skills_dir() + if active_skills_dir.exists(): + all_dirs.append(active_skills_dir) all_dirs.extend(get_external_skills_dirs()) if not all_dirs: @@ -1135,7 +1154,7 @@ def skill_view( # Security: warn if skill is loaded from outside trusted directories # (local skills dir + configured external_dirs are all trusted) _outside_skills_dir = True - _trusted_dirs = [SKILLS_DIR.resolve()] + _trusted_dirs = [active_skills_dir.resolve()] try: _trusted_dirs.extend(d.resolve() for d in all_dirs[1:]) except Exception: @@ -1375,7 +1394,7 @@ def skill_view( linked_files["scripts"] = script_files try: - rel_path = str(skill_md.relative_to(SKILLS_DIR)) + rel_path = str(skill_md.relative_to(active_skills_dir)) except ValueError: # External skill — use path relative to the skill's own parent dir rel_path = str(skill_md.relative_to(skill_md.parent.parent)) if skill_md.parent.parent else skill_md.name From 4a99571d54e0e0ca0dddd7918ba32446ac115362 Mon Sep 17 00:00:00 2001 From: Luke The Dev Date: Sat, 6 Jun 2026 22:14:23 -0500 Subject: [PATCH 099/610] fix(tui): pass profile_home to slash_worker subprocess for profile-local skill discovery (#40677) Profile-local skills are unavailable in Dashboard/TUI/Desktop GUI because the _SlashWorker subprocess is spawned with os.environ.copy() but does NOT receive the profile-specific HERMES_HOME from the parent session. This causes the subprocess to search ~/.hermes instead of the active profile's skills directory. 1. Modify _SlashWorker.__init__ to accept optional profile_home parameter 2. When profile_home is provided, set env['HERMES_HOME'] = profile_home before spawning the subprocess 3. Update all 4 call sites to pass profile_home=session.get('profile_home') 4. Add regression tests for profile-home propagation - Full TUI gateway test suite: 107 tests pass - New tests cover: - profile_home parameter acceptance - backward compatibility (None, omitted) - argv correctness Fixes #40677 --- .../test_slash_worker_profile_home.py | 124 ++++++++++++++++++ tui_gateway/server.py | 29 +++- 2 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 tests/tui_gateway/test_slash_worker_profile_home.py diff --git a/tests/tui_gateway/test_slash_worker_profile_home.py b/tests/tui_gateway/test_slash_worker_profile_home.py new file mode 100644 index 000000000000..18f321b3b14a --- /dev/null +++ b/tests/tui_gateway/test_slash_worker_profile_home.py @@ -0,0 +1,124 @@ +"""Tests for TUI gateway slash_worker profile_home propagation (#40677).""" + +import os +import subprocess +import sys +from unittest.mock import MagicMock, patch, call + +import pytest + + +def test_slash_worker_accepts_profile_home(): + """_SlashWorker.__init__ accepts profile_home parameter.""" + with patch.dict("sys.modules", { + "hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")), + }): + with patch("subprocess.Popen") as mock_popen: + mock_popen.return_value.stdout = MagicMock() + mock_popen.return_value.stderr = MagicMock() + + from tui_gateway.server import _SlashWorker + + # Test initialization with profile_home + worker = _SlashWorker( + session_key="test_key", + model="test-model", + profile_home="/home/luke/.hermes/profiles/work" + ) + + # Verify Popen was called + assert mock_popen.called + + # Check that HERMES_HOME was set in the environment + call_kwargs = mock_popen.call_args[1] + assert "env" in call_kwargs + assert call_kwargs["env"]["HERMES_HOME"] == "/home/luke/.hermes/profiles/work" + + +def test_slash_worker_without_profile_home(): + """_SlashWorker works without profile_home parameter (backward compatible).""" + with patch.dict("sys.modules", { + "hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")), + }): + with patch("subprocess.Popen") as mock_popen: + mock_popen.return_value.stdout = MagicMock() + mock_popen.return_value.stderr = MagicMock() + + from tui_gateway.server import _SlashWorker + + # Test initialization without profile_home (backward compatible) + worker = _SlashWorker( + session_key="test_key", + model="test-model" + ) + + # Verify Popen was called + assert mock_popen.called + + # Check that HERMES_HOME was NOT overridden + call_kwargs = mock_popen.call_args[1] + assert "env" in call_kwargs + # HERMES_HOME should be from parent env or undefined (inherited from os.environ) + # The key is that it's not explicitly set when profile_home is None + env = call_kwargs["env"] + # Verify env is a copy of os.environ + assert "PATH" in env + + +def test_slash_worker_with_none_profile_home(): + """_SlashWorker with explicit profile_home=None works.""" + with patch.dict("sys.modules", { + "hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")), + }): + with patch("subprocess.Popen") as mock_popen: + mock_popen.return_value.stdout = MagicMock() + mock_popen.return_value.stderr = MagicMock() + + from tui_gateway.server import _SlashWorker + + # Test initialization with explicit None + worker = _SlashWorker( + session_key="test_key", + model="test-model", + profile_home=None + ) + + # Verify Popen was called + assert mock_popen.called + + # Check that HERMES_HOME was NOT set + call_kwargs = mock_popen.call_args[1] + env = call_kwargs["env"] + # When profile_home is None, HERMES_HOME should come from parent env only + if "HERMES_HOME" in env: + # This is from os.environ at test time, not from our code + pass + + +def test_slash_worker_inherits_argv_correctly(): + """_SlashWorker passes correct argv to Popen.""" + with patch.dict("sys.modules", { + "hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")), + }): + with patch("subprocess.Popen") as mock_popen: + mock_popen.return_value.stdout = MagicMock() + mock_popen.return_value.stderr = MagicMock() + + from tui_gateway.server import _SlashWorker + + # Test that argv is correct + worker = _SlashWorker( + session_key="my_session", + model="gpt-4" + ) + + call_args = mock_popen.call_args[0][0] + + # Verify argv structure + assert sys.executable in call_args + assert "-m" in call_args + assert "tui_gateway.slash_worker" in call_args + assert "--session-key" in call_args + assert "my_session" in call_args + assert "--model" in call_args + assert "gpt-4" in call_args diff --git a/tui_gateway/server.py b/tui_gateway/server.py index db9826bbb2f3..8845128f9a56 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -275,7 +275,7 @@ _detached_ws_transport = _DropTransport() class _SlashWorker: """Persistent HermesCLI subprocess for slash commands.""" - def __init__(self, session_key: str, model: str): + def __init__(self, session_key: str, model: str, profile_home: str | None = None): self._lock = threading.Lock() self._seq = 0 self.stderr_tail: list[str] = [] @@ -294,6 +294,15 @@ class _SlashWorker: self._closed = False from hermes_cli._subprocess_compat import windows_hide_flags + # slash_worker runs the Hermes agent → needs provider credentials. + # Tier-1 secrets (gateway/GitHub/infra) are still stripped (#29157). + env = hermes_subprocess_env(inherit_credentials=True) + if profile_home: + # Global-remote / multi-profile sessions: the worker must resolve + # config/skills/state against the session's profile home, not the + # gateway's launch HERMES_HOME (#40677). + env["HERMES_HOME"] = str(profile_home) + # start_new_session=True detaches the slash worker into its own # process group / session. Without this, the worker inherits the # gateway's pgid (= TUI parent PID). When mcp_tool's @@ -310,9 +319,7 @@ class _SlashWorker: text=True, bufsize=1, cwd=os.getcwd(), - # slash_worker runs the Hermes agent → needs provider credentials. - # Tier-1 secrets (gateway/GitHub/infra) are still stripped (#29157). - env=hermes_subprocess_env(inherit_credentials=True), + env=env, creationflags=windows_hide_flags(), start_new_session=True, ) @@ -1299,7 +1306,11 @@ def _start_agent_build(sid: str, session: dict) -> None: current["config_model_seen"] = _config_model_target() try: - worker = _SlashWorker(key, getattr(agent, "model", _resolve_model())) + worker = _SlashWorker( + key, + getattr(agent, "model", _resolve_model()), + profile_home=current.get("profile_home"), + ) _attach_worker(sid, current, worker) except Exception: pass @@ -2628,6 +2639,7 @@ def _restart_slash_worker(sid: str, session: dict): new_worker = _SlashWorker( session["session_key"], getattr(session.get("agent"), "model", _resolve_model()), + profile_home=session.get("profile_home"), ) except Exception: session["slash_worker"] = None @@ -4514,7 +4526,11 @@ def _init_session( _attach_worker( sid, _sessions[sid], - _SlashWorker(key, getattr(agent, "model", _resolve_model())), + _SlashWorker( + key, + getattr(agent, "model", _resolve_model()), + profile_home=_sessions[sid].get("profile_home"), + ), ) except Exception: # Defer hard-failure to slash.exec; chat still works without slash worker. @@ -12710,6 +12726,7 @@ def _(rid, params: dict) -> dict: worker = _SlashWorker( session["session_key"], getattr(session.get("agent"), "model", _resolve_model()), + profile_home=session.get("profile_home"), ) _attach_worker(params.get("session_id", ""), session, worker) except Exception as e: From c6a3d412d462df8cc33080a873ec67eba1f3111d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:18:38 -0700 Subject: [PATCH 100/610] fix(skills): widen call-time skills-dir resolution to skill_manager_tool Same bug class as skills_tool: module-level SKILLS_DIR pinned at import under the launch HERMES_HOME makes skill_manage() write/edit against the wrong profile in long-lived multi-profile runtimes. Apply the same _skills_dir() call-time resolution (honoring explicit test patches of SKILLS_DIR) to _containing_skills_root, _resolve_skill_dir, _find_skill_in_other_profiles, and create-result path reporting. Refs #40677 --- tools/skill_manager_tool.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index 2d7d9fa9abea..3045474b1c94 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -150,6 +150,22 @@ import yaml # All skills live in ~/.hermes/skills/ (single source of truth) HERMES_HOME = get_hermes_home() SKILLS_DIR = HERMES_HOME / "skills" +_SKILLS_DIR_AT_IMPORT = SKILLS_DIR + + +def _skills_dir() -> Path: + """Return the active profile's skills directory at call time. + + Long-lived multi-profile runtimes (Dashboard/TUI/Desktop backend, cron, + kanban workers) import this module once under the launch HERMES_HOME and + later bind a different profile per session (#40677). Honor an explicitly + patched module-level ``SKILLS_DIR`` (tests), otherwise resolve from the + live profile-scoped HERMES_HOME on every call. + """ + configured = Path(SKILLS_DIR) + if configured != _SKILLS_DIR_AT_IMPORT: + return configured + return get_hermes_home() / "skills" MAX_NAME_LENGTH = 64 MAX_DESCRIPTION_LENGTH = 1024 @@ -174,7 +190,7 @@ def _containing_skills_root(skill_path: Path) -> Path: return root except (ValueError, OSError): continue - return SKILLS_DIR + return _skills_dir() def _is_path_redirect(path: Path) -> bool: @@ -562,8 +578,8 @@ def _validate_content_size(content: str, label: str = "SKILL.md") -> Optional[st def _resolve_skill_dir(name: str, category: str = None) -> Path: """Build the directory path for a new skill, optionally under a category.""" if category: - return SKILLS_DIR / category / name - return SKILLS_DIR / name + return _skills_dir() / category / name + return _skills_dir() / name def _find_skill(name: str) -> Optional[Dict[str, Any]]: @@ -608,8 +624,9 @@ def _find_skill_in_other_profiles(name: str) -> List[Tuple[str, Path]]: return matches # Collect (profile_name, skills_dir) for every profile EXCEPT the - # one whose SKILLS_DIR we already searched in _find_skill(). - active_dir = SKILLS_DIR.resolve() if SKILLS_DIR.exists() else SKILLS_DIR + # one whose skills dir we already searched in _find_skill(). + _active = _skills_dir() + active_dir = _active.resolve() if _active.exists() else _active candidates: List[Tuple[str, Path]] = [] # Default profile (~/.hermes/skills) — only consider when active is non-default. @@ -828,7 +845,7 @@ def _create_skill(name: str, content: str, category: str = None) -> Dict[str, An result = { "success": True, "message": f"Skill '{name}' created.", - "path": str(skill_dir.relative_to(SKILLS_DIR)), + "path": str(skill_dir.relative_to(_skills_dir())), "skill_md": str(skill_md), "_change": {"description": _desc}, } From 4f6313eadc62685f57ea6f24c9c1bbb41bbebdc0 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:32:40 -0700 Subject: [PATCH 101/610] test(tui): accept profile_home kwarg in _FakeWorker doubles _SlashWorker call sites now pass profile_home=; the fakes' 2-arg __init__ raised TypeError inside the spawn guard, leaving slash_worker=None and failing the orphan-race regression tests. --- tests/test_tui_gateway_server.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 0933be0213f1..3286363dfd23 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1947,7 +1947,7 @@ def test_init_session_fires_reset_hook(monkeypatch): hooks = [] class _FakeWorker: - def __init__(self, key, model): + def __init__(self, key, model, profile_home=None): self.key = key def close(self): @@ -5453,7 +5453,7 @@ def test_session_create_close_race_does_not_orphan_worker(monkeypatch): unregistered_keys: list[str] = [] class _FakeWorker: - def __init__(self, key, model): + def __init__(self, key, model, profile_home=None): self.key = key self._closed = False @@ -5574,7 +5574,7 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch): unregistered_keys: list[str] = [] class _FakeWorker: - def __init__(self, key, model): + def __init__(self, key, model, profile_home=None): self.key = key def close(self): @@ -5678,7 +5678,7 @@ def test_get_db_degrades_cleanly_when_sessiondb_init_fails(monkeypatch): def test_session_create_continues_when_state_db_is_unavailable(monkeypatch): class _FakeWorker: - def __init__(self, key, model): + def __init__(self, key, model, profile_home=None): self.key = key def close(self): @@ -5726,7 +5726,7 @@ def test_session_create_lazy_info_reports_desktop_contract(monkeypatch): date" on every launch even against a current backend.""" class _FakeWorker: - def __init__(self, key, model): + def __init__(self, key, model, profile_home=None): self.key = key def close(self): From a1e6ea7d716c3a31ccad3f7888fd181fa3ea6734 Mon Sep 17 00:00:00 2001 From: Eugeniusz Gilewski Date: Thu, 2 Jul 2026 00:34:13 +0200 Subject: [PATCH 102/610] fix(tools): keep shell snapshots owner-only BaseEnvironment writes shell snapshots and cwd metadata through the process umask. With a common 022 umask, snapshot files containing exported environment state landed at mode 0644 even though they can include env-carried credentials from the parent process. Set umask 077 only around Hermes metadata writes: the initial snapshot bootstrap and the post-command snapshot/cwd refresh. User commands still run under the caller's original umask, while Hermes-owned snapshot and cwd files are created owner-only. This intentionally does not copy the source PR's global orphan sweep; deleting all matching /tmp snapshot files could interfere with concurrent Hermes processes. The security-critical local disclosure fix is the file mode clamp. This is salvageable because the source report still identifies a concrete credential-disclosure path, but the safe subset is smaller than the original proposal: clamp only the Hermes-owned snapshot writes and leave process-wide cleanup, user command umask, and concurrent sessions alone. Salvages source PR: https://github.com/NousResearch/hermes-agent/pull/20056 Related issue: https://github.com/NousResearch/hermes-agent/issues/48441 Co-authored-by: Andrew Homeyer --- tests/tools/test_base_environment.py | 77 ++++++++++++++++++++++++++++ tools/environments/base.py | 4 ++ 2 files changed, 81 insertions(+) diff --git a/tests/tools/test_base_environment.py b/tests/tools/test_base_environment.py index 8d6e6ab73288..d629d95ea299 100644 --- a/tests/tools/test_base_environment.py +++ b/tests/tools/test_base_environment.py @@ -174,6 +174,32 @@ class TestAtomicSnapshotWrite: assert "$BASHPID" in boot assert ".tmp.$$" not in boot + def test_snapshot_writes_use_private_umask_after_user_command(self): + env = _TestableEnv() + env._snapshot_ready = True + wrapped = env._wrap_command("echo hi", "/tmp") + + assert "umask 077" in wrapped + assert wrapped.index("eval 'echo hi'") < wrapped.index("umask 077") + assert wrapped.index("umask 077") < wrapped.index("export -p >") + + def test_init_session_bootstrap_uses_private_umask(self): + env = _TestableEnv() + captured = {} + + def fake_run_bash(cmd_string, *, login=False, timeout=120, stdin_data=None): + captured["cmd"] = cmd_string + raise RuntimeError("stop after capture") + + env._run_bash = fake_run_bash # type: ignore[assignment] + try: + env.init_session() + except Exception: + pass + boot = captured.get("cmd", "") + assert "umask 077" in boot + assert boot.index("umask 077") < boot.index("export -p >") + class TestAtomicSnapshotConcurrencyBehavioral: """Behavioral regression for #38249 — actually EXECUTES the generated @@ -250,6 +276,57 @@ class TestAtomicSnapshotConcurrencyBehavioral: assert "export GOOD=1" in out.stdout, "good snapshot was destroyed by a failed export" +class TestSnapshotFileModes: + """Snapshot metadata files are private without changing user command umask.""" + + def test_snapshot_and_cwd_files_are_0600(self, tmp_path): + import os + from pathlib import Path + import shutil + import stat + import subprocess + if not shutil.which("bash"): + import pytest + pytest.skip("bash required") + + class ExecutableEnv(BaseEnvironment): + def __init__(self, temp_dir): + self._temp_dir = str(temp_dir) + super().__init__(cwd=str(temp_dir), timeout=10) + + def get_temp_dir(self): + return self._temp_dir + + def _run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None): + proc = subprocess.Popen( + ["/bin/bash", "-lc", cmd_string], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + text=True, + cwd=self.cwd, + ) + proc.communicate(timeout=timeout) + return proc + + def cleanup(self): + pass + + old_umask = os.umask(0o022) + try: + env = ExecutableEnv(tmp_path) + env.init_session() + + user_file = tmp_path / "user-created.txt" + env.execute(f"touch {user_file}") + + assert stat.S_IMODE(user_file.stat().st_mode) == 0o644 + assert stat.S_IMODE(Path(env._snapshot_path).stat().st_mode) == 0o600 + assert stat.S_IMODE(Path(env._cwd_file).stat().st_mode) == 0o600 + finally: + os.umask(old_umask) + + class TestExtractCwdFromOutput: def test_happy_path(self): env = _TestableEnv() diff --git a/tools/environments/base.py b/tools/environments/base.py index ef5b683aad2c..93d56c0c5ced 100644 --- a/tools/environments/base.py +++ b/tools/environments/base.py @@ -395,6 +395,7 @@ class BaseEnvironment(ABC): # with ``$BASHPID`` left outside the quotes so it still expands. _snap_tmp = shlex.quote(self._snapshot_path + ".tmp.") + "$BASHPID" bootstrap = ( + f"umask 077\n" f"export -p > {_snap_tmp}\n" # Dump function definitions, filtering out private (``_``-prefixed) # helpers — mainly bash-completion internals (``_git``, ``_make``…) @@ -502,6 +503,9 @@ class BaseEnvironment(ABC): # Run the actual command parts.append(f"eval '{escaped}'") parts.append("__hermes_ec=$?") + # Restrict Hermes metadata files without changing the user's command + # umask. Snapshot files may contain env-carried secrets. + parts.append("umask 077") # Re-dump env vars to snapshot (atomic replacement to avoid races). # Chain mv on the export succeeding so a failed/partial dump never From 685f527d6b4fd7938248a9852a8d7104e787ea49 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:38:04 -0700 Subject: [PATCH 103/610] chore: add andrewhomeyer to AUTHOR_MAP (co-author on snapshot perms salvage) --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 9127dc38410b..776b228c1578 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1812,6 +1812,7 @@ AUTHOR_MAP = { "vanthinh6886@gmail.com": "vanthinh6886", # PR #28018 salvage (yaml/flock/atomic write guards) "erik.engervall@gmail.com": "erikengervall", # PR #28774 (firecrawl integration tag) "egilewski@egilewski.com": "egilewski", # PR #30432 (MEDIA path traversal fix, GHSA-jmf9-9729-7pp8) + "andrew@hndl.app": "andrewhomeyer", # PR #20056 (snapshot owner-only perms, co-author on #57386 salvage) "edison@mcclean.codes": "McClean-Edison", # PR #29817 (register_auxiliary_task plugin API) "OYLFLMH@users.noreply.github.com": "OYLFLMH", # PR #48312 salvage (cli_refresh_interval config, #48309) "zhangsamuel12@gmail.com": "SamuelZ12", # PR #7480 (show recap after in-session resume) From afb5808d8c9e0418ab14107a1c1f8b5598d6489b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:50:08 -0700 Subject: [PATCH 104/610] feat(discord): make interactive view timeout configurable (#60230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord's ExecApprovalView, SlashConfirmView, UpdatePromptView, and ClarifyChoiceView hardcoded timeout=300, ignoring approval timeout configuration. All four now read approvals.discord_prompt_timeout from config.yaml (default 300s, clamped 30-900s — Discord interaction tokens expire at ~15 min, so values beyond 900s would render dead buttons). Surgical reapply of the timeout portion of PR #45904; the unrelated channel-context changes bundled in that PR were intentionally excluded. Co-authored-by: cruzanstx --- plugins/platforms/discord/adapter.py | 50 +++++- .../test_discord_prompt_timeout_config.py | 150 ++++++++++++++++++ 2 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 tests/gateway/test_discord_prompt_timeout_config.py diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 641215bd705d..4e466433619d 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -743,6 +743,48 @@ def _read_dm_role_auth_guild() -> Optional[int]: return guild_id if guild_id > 0 else None +# Default timeout for Discord interactive button views (exec approval, slash +# confirm, update prompt, clarify choice). Used when the user has not set +# ``approvals.discord_prompt_timeout`` in config.yaml. 300s (5 min) matches +# the previous hardcoded value. Bounded to a sane range — Discord +# interaction tokens expire from the API's side at ~15 minutes, so 900s is +# the practical ceiling. +_DISCORD_PROMPT_TIMEOUT_DEFAULT = 300 +_DISCORD_PROMPT_TIMEOUT_MIN = 30 +_DISCORD_PROMPT_TIMEOUT_MAX = 900 + + +def _read_discord_prompt_timeout() -> int: + """Return the timeout (in seconds) for Discord button views. + + Reads ``approvals.discord_prompt_timeout`` from config.yaml. Falls back + to the historical 300s default for any missing / malformed value, and + clamps the result to ``[_DISCORD_PROMPT_TIMEOUT_MIN, + _DISCORD_PROMPT_TIMEOUT_MAX]`` so a typo can't accidentally make + interactive prompts disappear (too short) or outlive Discord's own + 15-minute interaction-token expiry (too long). + """ + raw: Any = None + try: + from hermes_cli.config import read_raw_config + cfg = read_raw_config() or {} + approvals_cfg = cfg.get("approvals", {}) or {} + raw = approvals_cfg.get("discord_prompt_timeout") + except Exception: + return _DISCORD_PROMPT_TIMEOUT_DEFAULT + if raw is None or raw == "": + return _DISCORD_PROMPT_TIMEOUT_DEFAULT + try: + seconds = int(raw) + except (TypeError, ValueError): + return _DISCORD_PROMPT_TIMEOUT_DEFAULT + if seconds < _DISCORD_PROMPT_TIMEOUT_MIN: + return _DISCORD_PROMPT_TIMEOUT_MIN + if seconds > _DISCORD_PROMPT_TIMEOUT_MAX: + return _DISCORD_PROMPT_TIMEOUT_MAX + return seconds + + class DiscordAdapter(BasePlatformAdapter): """ Discord bot adapter. @@ -6594,7 +6636,7 @@ def _define_discord_view_classes() -> None: require_admin: bool = False, admin_user_ids: Optional[set] = None, ): - super().__init__(timeout=300) # 5-minute timeout + super().__init__(timeout=_read_discord_prompt_timeout()) self.session_key = session_key self.allowed_user_ids = allowed_user_ids self.allowed_role_ids = allowed_role_ids or set() @@ -6748,7 +6790,7 @@ def _define_discord_view_classes() -> None: allowed_user_ids: set, allowed_role_ids: Optional[set] = None, ): - super().__init__(timeout=300) + super().__init__(timeout=_read_discord_prompt_timeout()) self.session_key = session_key self.confirm_id = confirm_id self.allowed_user_ids = allowed_user_ids @@ -6853,7 +6895,7 @@ def _define_discord_view_classes() -> None: allowed_user_ids: set, allowed_role_ids: Optional[set] = None, ): - super().__init__(timeout=300) + super().__init__(timeout=_read_discord_prompt_timeout()) self.session_key = session_key self.allowed_user_ids = allowed_user_ids self.allowed_role_ids = allowed_role_ids or set() @@ -7286,7 +7328,7 @@ def _define_discord_view_classes() -> None: allowed_user_ids: set, allowed_role_ids: Optional[set] = None, ): - super().__init__(timeout=300) # 5-minute timeout + super().__init__(timeout=_read_discord_prompt_timeout()) self.choices = list(choices)[:24] self.clarify_id = clarify_id self.allowed_user_ids = allowed_user_ids diff --git a/tests/gateway/test_discord_prompt_timeout_config.py b/tests/gateway/test_discord_prompt_timeout_config.py new file mode 100644 index 000000000000..735359fff181 --- /dev/null +++ b/tests/gateway/test_discord_prompt_timeout_config.py @@ -0,0 +1,150 @@ +"""Tests for the configurable Discord interactive-view timeout. + +Previously hardcoded to 300s on ExecApprovalView, SlashConfirmView, +UpdatePromptView, and ClarifyChoiceView. Now reads +``approvals.discord_prompt_timeout`` with the same 300s default, clamped to +``[_DISCORD_PROMPT_TIMEOUT_MIN, _DISCORD_PROMPT_TIMEOUT_MAX]`` so a typo +can't make prompts disappear (too short) or outlive Discord's 15-min +interaction-token expiry (too long). +""" + +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + + +def _ensure_discord_mock(): + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return + + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object) + discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3) + discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + + ext_mod = MagicMock() + commands_mod = MagicMock() + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + + sys.modules.setdefault("discord", discord_mod) + sys.modules.setdefault("discord.ext", ext_mod) + sys.modules.setdefault("discord.ext.commands", commands_mod) + + +_ensure_discord_mock() + +from plugins.platforms.discord.adapter import ( # noqa: E402 + _DISCORD_PROMPT_TIMEOUT_DEFAULT, + _DISCORD_PROMPT_TIMEOUT_MAX, + _DISCORD_PROMPT_TIMEOUT_MIN, + _read_discord_prompt_timeout, +) + + +def _patch_config(monkeypatch, cfg): + """Stub ``hermes_cli.config.read_raw_config`` to return ``cfg``.""" + import hermes_cli.config + monkeypatch.setattr(hermes_cli.config, "read_raw_config", lambda: cfg) + + +def test_default_when_config_absent(monkeypatch): + _patch_config(monkeypatch, {}) + assert _read_discord_prompt_timeout() == _DISCORD_PROMPT_TIMEOUT_DEFAULT + + +def test_default_when_approvals_block_missing(monkeypatch): + _patch_config(monkeypatch, {"other": {}}) + assert _read_discord_prompt_timeout() == _DISCORD_PROMPT_TIMEOUT_DEFAULT + + +def test_default_when_key_missing(monkeypatch): + _patch_config(monkeypatch, {"approvals": {"mode": "manual"}}) + assert _read_discord_prompt_timeout() == _DISCORD_PROMPT_TIMEOUT_DEFAULT + + +def test_explicit_int_value(monkeypatch): + _patch_config(monkeypatch, {"approvals": {"discord_prompt_timeout": 600}}) + assert _read_discord_prompt_timeout() == 600 + + +def test_numeric_string_accepted(monkeypatch): + """YAML parsers occasionally return numbers as strings; tolerate it.""" + _patch_config(monkeypatch, {"approvals": {"discord_prompt_timeout": "450"}}) + assert _read_discord_prompt_timeout() == 450 + + +def test_malformed_value_falls_back_to_default(monkeypatch): + _patch_config( + monkeypatch, + {"approvals": {"discord_prompt_timeout": "five minutes"}}, + ) + assert _read_discord_prompt_timeout() == _DISCORD_PROMPT_TIMEOUT_DEFAULT + + +def test_value_clamped_to_minimum(monkeypatch): + """A typo of e.g. 5 seconds must not make prompts disappear.""" + _patch_config(monkeypatch, {"approvals": {"discord_prompt_timeout": 5}}) + assert _read_discord_prompt_timeout() == _DISCORD_PROMPT_TIMEOUT_MIN + + +def test_value_clamped_to_maximum(monkeypatch): + """Discord interaction tokens expire at ~15 min — clamp larger values.""" + _patch_config(monkeypatch, {"approvals": {"discord_prompt_timeout": 99999}}) + assert _read_discord_prompt_timeout() == _DISCORD_PROMPT_TIMEOUT_MAX + + +def test_zero_clamped_to_minimum(monkeypatch): + _patch_config(monkeypatch, {"approvals": {"discord_prompt_timeout": 0}}) + assert _read_discord_prompt_timeout() == _DISCORD_PROMPT_TIMEOUT_MIN + + +def test_negative_clamped_to_minimum(monkeypatch): + _patch_config(monkeypatch, {"approvals": {"discord_prompt_timeout": -300}}) + assert _read_discord_prompt_timeout() == _DISCORD_PROMPT_TIMEOUT_MIN + + +def test_empty_string_falls_back_to_default(monkeypatch): + _patch_config(monkeypatch, {"approvals": {"discord_prompt_timeout": ""}}) + assert _read_discord_prompt_timeout() == _DISCORD_PROMPT_TIMEOUT_DEFAULT + + +def test_config_read_exception_falls_back_to_default(monkeypatch): + """A crashing read_raw_config must not bring down view construction — + falling back to the historical 300s default preserves existing behavior. + """ + import hermes_cli.config + def _boom(): + raise RuntimeError("config file corrupt") + monkeypatch.setattr(hermes_cli.config, "read_raw_config", _boom) + assert _read_discord_prompt_timeout() == _DISCORD_PROMPT_TIMEOUT_DEFAULT + + +def test_default_matches_previous_hardcoded_value(): + """Behavioral parity assertion: existing installs (no new config) must + see exactly the 300s timeout the views were hardcoded to before this + change. Guards against the default drifting in a future refactor. + """ + assert _DISCORD_PROMPT_TIMEOUT_DEFAULT == 300 + + +def test_clamp_range_includes_default(): + """Sanity: the default must lie inside the clamp range, or every fresh + install would hit the clamp on its very first read. + """ + assert _DISCORD_PROMPT_TIMEOUT_MIN <= _DISCORD_PROMPT_TIMEOUT_DEFAULT <= _DISCORD_PROMPT_TIMEOUT_MAX From cc0aa18fe718967bc5032271f69d3c2fdd85a5b9 Mon Sep 17 00:00:00 2001 From: vampyren <4087127+vampyren@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:26:04 -0700 Subject: [PATCH 105/610] feat(kanban): add grab-to-pan board scrolling Adds grab-to-pan horizontal scrolling to the Kanban dashboard board columns with grab/grabbing cursor feedback. Card drag-and-drop, add buttons, checkboxes, links, and inputs are excluded from panning, the native horizontal scrollbar remains usable as a fallback, and panning state is cleared on mouse release or window blur. Salvaged from PR #59830 by @vampyren (original commit authored under an unlinked local git identity; rewritten to their GitHub noreply address to preserve attribution). --- plugins/kanban/dashboard/dist/index.js | 99 ++++++++++++++++++++++++- plugins/kanban/dashboard/dist/style.css | 9 +++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index d932bb1d24f6..a801a87327c0 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -2273,6 +2273,93 @@ // ------------------------------------------------------------------------- function BoardColumns(props) { + const columnsRef = useRef(null); + const panRef = useRef({ isPanning: false, startX: 0, scrollLeft: 0 }); + const [isPanning, setIsPanning] = useState(false); + const [isScrollable, setIsScrollable] = useState(false); + + const checkScrollable = useCallback(function () { + const el = columnsRef.current; + setIsScrollable(!!el && el.scrollWidth > el.clientWidth + 1); + }, []); + + useEffect(function () { + checkScrollable(); + const el = columnsRef.current; + if (!el) return undefined; + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(checkScrollable); + observer.observe(el); + return function () { observer.disconnect(); }; + } + window.addEventListener("resize", checkScrollable); + return function () { window.removeEventListener("resize", checkScrollable); }; + }, [checkScrollable, props.board]); + + const isPanBlockedTarget = useCallback(function (target) { + if (!target) return true; + if (target.closest && target.closest(".hermes-kanban-card")) return true; + if (target.closest && target.closest(".hermes-kanban-column-add")) return true; + if (target.closest && target.closest(".hermes-kanban-col-check")) return true; + if (target.closest && target.closest("button,input,textarea,select,a,[role='button']")) return true; + return false; + }, []); + + const stopPan = useCallback(function () { + const el = columnsRef.current; + if (!panRef.current.isPanning) return; + panRef.current.isPanning = false; + setIsPanning(false); + if (el) { + // Keep cursor feedback instant even before React flushes the state update. + el.classList.remove("hermes-kanban-columns--panning"); + el.style.userSelect = ""; + } + if (panRef.current.cleanup) panRef.current.cleanup(); + panRef.current.cleanup = null; + }, []); + + useEffect(function () { + return function () { stopPan(); }; + }, [stopPan]); + + const handleMouseDown = useCallback(function (e) { + if (e.button !== 0) return; + if (isPanBlockedTarget(e.target)) return; + const el = columnsRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + // Preserve the native horizontal scrollbar as a fallback; grab-pan starts above it. + if (e.clientY >= rect.bottom - 20) return; + if (el.scrollWidth <= el.clientWidth) return; + + panRef.current.isPanning = true; + panRef.current.startX = e.clientX; + panRef.current.scrollLeft = el.scrollLeft; + setIsPanning(true); + el.classList.add("hermes-kanban-columns--panning"); + el.style.userSelect = "none"; + + function onMouseMove(ev) { + if (!panRef.current.isPanning) return; + const dx = ev.clientX - panRef.current.startX; + el.scrollLeft = panRef.current.scrollLeft - dx; + ev.preventDefault(); + } + function onMouseUp() { stopPan(); } + + if (panRef.current.cleanup) panRef.current.cleanup(); + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp, { once: true }); + window.addEventListener("blur", onMouseUp, { once: true }); + panRef.current.cleanup = function () { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + window.removeEventListener("blur", onMouseUp); + }; + e.preventDefault(); + }, [isPanBlockedTarget, stopPan]); + const handleDragStart = useCallback(function (e) { const card = e.target.closest && e.target.closest(".hermes-kanban-card"); if (!card) return; @@ -2282,7 +2369,17 @@ const handleDragEnd = useCallback(function () { if (props.onDragEnd) props.onDragEnd(); }, [props.onDragEnd]); - return h("div", { className: "hermes-kanban-columns", onDragStart: handleDragStart, onDragEnd: handleDragEnd }, + return h("div", { + ref: columnsRef, + className: cn( + "hermes-kanban-columns", + isScrollable ? "hermes-kanban-columns--scrollable" : "", + isPanning ? "hermes-kanban-columns--panning" : "", + ), + onDragStart: handleDragStart, + onDragEnd: handleDragEnd, + onMouseDown: handleMouseDown, + }, props.board.columns.map(function (col) { return h(Column, { key: col.name, diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css index 6b396b2612ef..8f2a43c295ff 100644 --- a/plugins/kanban/dashboard/dist/style.css +++ b/plugins/kanban/dashboard/dist/style.css @@ -69,6 +69,15 @@ overflow-x: auto; } +.hermes-kanban-columns--scrollable { + cursor: grab; +} + +.hermes-kanban-columns--panning, +.hermes-kanban-columns--panning * { + cursor: grabbing !important; +} + .hermes-kanban-column { flex: 0 0 280px; display: flex; From 3c63ed3a3c81fd3d924128f4be51df7e7c21cd06 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:27:24 -0700 Subject: [PATCH 106/610] chore: add vampyren to AUTHOR_MAP (PR #59830 salvage) --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 776b228c1578..51bc07a8a890 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "4087127+vampyren@users.noreply.github.com": "vampyren", # PR #59830 salvage (kanban: grab-to-pan board scrolling; original commit under unlinked local identity) "spiky02plateau@users.noreply.github.com": "spiky02plateau", # PR #32824 salvage (usage: fetch Codex account limits from the credential pool in pool-only setups; superseded by #60028) "taylorhp@gmail.com": "hwrdprkns", # PR #36896 salvage (secrets: 1Password op:// secret source + shared _cache substrate, adapted onto the SecretSource interface) "ishengeqi@163.com": "isheng-eqi", # PR #59428 salvage (cron: reject past one-shot timestamps in update_job fallback + resume_job; #59395). Also PR #59446 salvage (cron: advance one-shot next_run_at before dispatch so concurrent gateway+desktop schedulers can't double-execute; #59229). From 2acbdd1848b7d48e1471ce39fc296f12300824d4 Mon Sep 17 00:00:00 2001 From: Jake Present Date: Thu, 25 Jun 2026 10:20:22 -0400 Subject: [PATCH 107/610] fix(discord): include approval command in message content --- plugins/platforms/discord/adapter.py | 41 +++++++++-- .../test_discord_exec_approval_content.py | 68 +++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 tests/gateway/test_discord_exec_approval_content.py diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 4e466433619d..027cc73949c5 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -5453,15 +5453,44 @@ class DiscordAdapter(BasePlatformAdapter): if not channel: channel = await self._client.fetch_channel(int(target_id)) - # Discord embed description limit is 4096; show full command up to that - max_desc = 4088 - cmd_display = command if len(command) <= max_desc else command[: max_desc - 3] + "..." + # Keep the approval request self-contained in plain message content. + # Discord embeds can be invisible or visually separated from the + # component row on some clients (notably web/mobile), so the actual + # command and reason must be visible in the same content block as + # the approval buttons. + reason_budget = 300 + reason_display = str(description or "dangerous command") + if len(reason_display) > reason_budget: + reason_display = reason_display[: reason_budget - 15] + "... [truncated]" + + prompt_prefix = ( + "⚠️ **Command Approval Required**\n\n" + "Do you want Hermes to run this command?\n\n" + "**Requested command:**\n```bash\n" + ) + prompt_tail = f"\n```\n**Reason:** {reason_display}" + truncated_suffix = "\n... [truncated]" + command_budget = max(0, self.MAX_MESSAGE_LENGTH - len(prompt_prefix) - len(prompt_tail)) + content_cmd_display = str(command or "") + if len(content_cmd_display) > command_budget: + content_cmd_display = ( + content_cmd_display[: max(0, command_budget - len(truncated_suffix))] + + truncated_suffix + ) + content = f"{prompt_prefix}{content_cmd_display}{prompt_tail}" + + # Preserve the richer embed path and its larger description budget + # for clients where embeds render correctly. + max_embed_desc = 4088 + embed_cmd_display = str(command or "") + if len(embed_cmd_display) > max_embed_desc: + embed_cmd_display = embed_cmd_display[: max_embed_desc - 3] + "..." embed = discord.Embed( title="⚠️ Command Approval Required", - description=f"```\n{cmd_display}\n```", + description=f"```\n{embed_cmd_display}\n```", color=discord.Color.orange(), ) - embed.add_field(name="Reason", value=description, inline=False) + embed.add_field(name="Reason", value=reason_display, inline=False) require_admin, admin_user_ids = _resolve_exec_approval_admin_gate( getattr(self.config, "extra", None) @@ -5474,7 +5503,7 @@ class DiscordAdapter(BasePlatformAdapter): admin_user_ids=admin_user_ids, ) - msg = await channel.send(embed=embed, view=view) + msg = await channel.send(content=content, embed=embed, view=view) view._message = msg # store for on_timeout expiration editing return SendResult(success=True, message_id=str(msg.id)) diff --git a/tests/gateway/test_discord_exec_approval_content.py b/tests/gateway/test_discord_exec_approval_content.py new file mode 100644 index 000000000000..20a9e85a5b6d --- /dev/null +++ b/tests/gateway/test_discord_exec_approval_content.py @@ -0,0 +1,68 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from gateway.config import PlatformConfig +from plugins.platforms.discord.adapter import DiscordAdapter + + +def _capture_channel(adapter): + sent = {} + + async def fake_send(**kwargs): + sent.update(kwargs) + return SimpleNamespace(id=1234) + + channel = SimpleNamespace(send=AsyncMock(side_effect=fake_send)) + adapter._client = SimpleNamespace( + get_channel=lambda _chat_id: channel, + fetch_channel=AsyncMock(), + ) + return sent + + +@pytest.mark.asyncio +async def test_exec_approval_prompt_uses_visible_content_with_command_and_reason(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + sent = _capture_channel(adapter) + + command = "python scripts/deploy.py --env prod --force" + result = await adapter.send_exec_approval( + chat_id="555", + command=command, + session_key="discord:555", + description="script execution via -c flag", + ) + + assert result.success is True + assert sent["view"] is not None + assert sent["embed"] is not None + + prompt_text = sent["content"] + assert "Command Approval Required" in prompt_text + assert "Do you want Hermes to run this command?" in prompt_text + assert "Requested command" in prompt_text + assert command in prompt_text + assert "Reason" in prompt_text + assert "script execution via -c flag" in prompt_text + + +@pytest.mark.asyncio +async def test_exec_approval_prompt_truncates_long_command_in_content(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + sent = _capture_channel(adapter) + + long_command = "python -c '" + ("x" * 5000) + "'" + result = await adapter.send_exec_approval( + chat_id="555", + command=long_command, + session_key="discord:555", + description="long generated shell command", + ) + + assert result.success is True + assert len(sent["content"]) <= adapter.MAX_MESSAGE_LENGTH + assert "... [truncated]" in sent["content"] + assert "long generated shell command" in sent["content"] + assert len(sent["embed"].description) > len(sent["content"]) From 009b42d008b81c18af39414dded9ecdf06082d93 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:01:49 -0700 Subject: [PATCH 108/610] fix(discord): mirror all interactive prompt payloads into message content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the send_exec_approval embed-invisibility fix to its three sibling prompt surfaces — send_slash_confirm, send_clarify, and send_update_prompt — via a shared _self_contained_prompt_content() helper. All four interactive views now carry their payload in plain content next to the buttons; the embed stays as progressive enhancement for clients that render it. Adds gold to the conftest discord Color mock (update prompt is the only gold user). --- plugins/platforms/discord/adapter.py | 50 ++++++- tests/gateway/conftest.py | 1 + .../test_discord_prompt_content_siblings.py | 123 ++++++++++++++++++ 3 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 tests/gateway/test_discord_prompt_content_siblings.py diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 027cc73949c5..dc48a336f338 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -5429,6 +5429,29 @@ class DiscordAdapter(BasePlatformAdapter): ) return None + def _self_contained_prompt_content( + self, header: str, body: str, *, code_block: bool = False, tail: str = "" + ) -> str: + """Build plain message content that mirrors an embed's payload. + + Discord embeds can be invisible or visually separated from the + component row on some clients (notably web/mobile), so interactive + prompts must carry their payload in plain ``content`` next to the + buttons. The embed stays as progressive enhancement. + """ + body = str(body or "") + if code_block: + prefix = f"{header}\n```bash\n" + suffix = f"\n```{tail}" + else: + prefix = f"{header}\n\n" + suffix = tail + truncated_suffix = "\n... [truncated]" + budget = max(0, self.MAX_MESSAGE_LENGTH - len(prefix) - len(suffix)) + if len(body) > budget: + body = body[: max(0, budget - len(truncated_suffix))] + truncated_suffix + return f"{prefix}{body}{suffix}" + async def send_exec_approval( self, chat_id: str, command: str, session_key: str, description: str = "dangerous command", @@ -5535,6 +5558,11 @@ class DiscordAdapter(BasePlatformAdapter): description=body, color=discord.Color.orange(), ) + # Mirror the payload in plain content — embeds are invisible on + # some clients (see send_exec_approval). + content = self._self_contained_prompt_content( + f"**{title or 'Confirm'}**", message + ) view = SlashConfirmView( session_key=session_key, @@ -5543,7 +5571,7 @@ class DiscordAdapter(BasePlatformAdapter): allowed_role_ids=self._allowed_role_ids, ) - msg = await channel.send(embed=embed, view=view) + msg = await channel.send(content=content, embed=embed, view=view) view._message = msg # store for on_timeout expiration editing return SendResult(success=True, message_id=str(msg.id)) except Exception as e: @@ -5657,7 +5685,18 @@ class DiscordAdapter(BasePlatformAdapter): ) view = None - msg = await channel.send(embed=embed, view=view) if view else await channel.send(embed=embed) + # Mirror the question in plain content — embeds are invisible on + # some clients (see send_exec_approval). + clarify_tail = ( + "\n\nPick one below, or click ✏️ Other to type a custom answer." + if clean_choices + else "\n\nReply in this channel with your answer." + ) + content = self._self_contained_prompt_content( + "❓ **Hermes needs your input**", str(question or "").strip(), + tail=clarify_tail, + ) + msg = await channel.send(content=content, embed=embed, view=view) if view else await channel.send(content=content, embed=embed) if view: view._message = msg # store for on_timeout expiration editing return SendResult(success=True, message_id=str(msg.id)) @@ -5694,7 +5733,12 @@ class DiscordAdapter(BasePlatformAdapter): allowed_user_ids=self._allowed_user_ids, allowed_role_ids=self._allowed_role_ids, ) - msg = await channel.send(embed=embed, view=view) + # Mirror the prompt in plain content — embeds are invisible on + # some clients (see send_exec_approval). + content = self._self_contained_prompt_content( + "⚕ **Update Needs Your Input**", f"{prompt}{default_hint}" + ) + msg = await channel.send(content=content, embed=embed, view=view) view._message = msg # store for on_timeout expiration editing if _metadata_marks_nonconversational(metadata): self._nonconversational_messages.mark_many([str(msg.id)]) diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py index d1f47c700b22..3546b09cc754 100644 --- a/tests/gateway/conftest.py +++ b/tests/gateway/conftest.py @@ -191,6 +191,7 @@ def _ensure_discord_mock() -> None: discord_mod.Color = SimpleNamespace( orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5, greyple=lambda: 6, + gold=lambda: 7, ) # app_commands — needed by _register_slash_commands auto-registration diff --git a/tests/gateway/test_discord_prompt_content_siblings.py b/tests/gateway/test_discord_prompt_content_siblings.py new file mode 100644 index 000000000000..77629f1412bc --- /dev/null +++ b/tests/gateway/test_discord_prompt_content_siblings.py @@ -0,0 +1,123 @@ +"""Sibling coverage for the embed-invisibility fix (send_exec_approval got it +in the same PR): slash confirm, clarify, and update prompts must also mirror +their payload into plain message content, since embeds don't render on some +Discord clients (web/mobile).""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from gateway.config import PlatformConfig +from plugins.platforms.discord.adapter import DiscordAdapter + + +def _capture_channel(adapter): + sent = {} + + async def fake_send(**kwargs): + sent.update(kwargs) + return SimpleNamespace(id=1234) + + channel = SimpleNamespace(send=AsyncMock(side_effect=fake_send)) + adapter._client = SimpleNamespace( + get_channel=lambda _chat_id: channel, + fetch_channel=AsyncMock(), + ) + return sent + + +@pytest.mark.asyncio +async def test_slash_confirm_mirrors_message_into_content(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + sent = _capture_channel(adapter) + + result = await adapter.send_slash_confirm( + chat_id="555", + title="Reset session?", + message="This will clear the current conversation history.", + session_key="discord:555", + confirm_id="c1", + ) + + assert result.success is True + assert sent["view"] is not None + assert sent["embed"] is not None + assert "Reset session?" in sent["content"] + assert "clear the current conversation history" in sent["content"] + + +@pytest.mark.asyncio +async def test_slash_confirm_truncates_long_message_in_content(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + sent = _capture_channel(adapter) + + result = await adapter.send_slash_confirm( + chat_id="555", + title="Confirm", + message="y" * 5000, + session_key="discord:555", + confirm_id="c2", + ) + + assert result.success is True + assert len(sent["content"]) <= adapter.MAX_MESSAGE_LENGTH + assert "... [truncated]" in sent["content"] + + +@pytest.mark.asyncio +async def test_clarify_with_choices_mirrors_question_into_content(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + sent = _capture_channel(adapter) + + result = await adapter.send_clarify( + chat_id="555", + question="Which environment should I deploy to?", + choices=["staging", "production"], + clarify_id="cl1", + session_key="discord:555", + ) + + assert result.success is True + assert sent["view"] is not None + assert "Hermes needs your input" in sent["content"] + assert "Which environment should I deploy to?" in sent["content"] + assert "Pick one below" in sent["content"] + + +@pytest.mark.asyncio +async def test_clarify_without_choices_mirrors_question_and_reply_hint(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + sent = _capture_channel(adapter) + + result = await adapter.send_clarify( + chat_id="555", + question="What should the cron schedule be?", + choices=[], + clarify_id="cl2", + session_key="discord:555", + ) + + assert result.success is True + assert sent.get("view") is None + assert "What should the cron schedule be?" in sent["content"] + assert "Reply in this channel" in sent["content"] + + +@pytest.mark.asyncio +async def test_update_prompt_mirrors_prompt_into_content(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + sent = _capture_channel(adapter) + + result = await adapter.send_update_prompt( + chat_id="555", + prompt="Restore stashed changes?", + default="yes", + session_key="discord:555", + ) + + assert result.success is True + assert sent["view"] is not None + assert "Update Needs Your Input" in sent["content"] + assert "Restore stashed changes?" in sent["content"] + assert "(default: yes)" in sent["content"] From 2bc6e1a74b5848332c606d1b20c97aa7f374947d Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Tue, 7 Jul 2026 21:17:34 +0700 Subject: [PATCH 109/610] fix(installer): put node.exe on PATH for Windows npm lifecycle scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm postinstall hooks spawn cmd.exe child processes that could not resolve `node` even when the installer found npm — causing desktop workspace npm install to fail with exit 1 (#48130). --- scripts/install.ps1 | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 179bca54b742..9712e293e42b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -502,6 +502,26 @@ function Sync-EnvPath { $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") } +# npm lifecycle scripts on Windows spawn ``cmd.exe /d /s /c node + + +""" + +def _escape_html(text: str) -> str: + if not isinstance(text, str): + text = str(text) + return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """).replace("'", "'") + +def _format_timestamp(ts: float) -> str: + if not ts: return "N/A" + return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") + +def _generate_messages_html(messages: List[Dict[str, Any]]) -> str: + html_list = [] + for i, msg in enumerate(messages): + role = msg.get("role", "unknown") + content = msg.get("content") or "" + timestamp = _format_timestamp(msg.get("timestamp", 0)) + + # Icon selection + role_icon = ICON_TERMINAL + if role == "user": + role_icon = ICON_USER + elif role == "assistant": + role_icon = ICON_BOT + + # Handle multimodal or complex content + if isinstance(content, list): + content_parts = [] + for part in content: + if isinstance(part, dict): + if part.get("type") == "text": + content_parts.append(part.get("text", "")) + elif part.get("type") == "image_url": + content_parts.append("[Image Attachment]") + else: + content_parts.append(str(part)) + content = "\n".join(content_parts) + + # Build message HTML + msg_class = f"message message-{role} active" + # Delay animation for initial items + delay_style = f' style="animation-delay: {min(i * 0.05, 1.0)}s"' if i < 10 else "" + + chevron_html = ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ') + + html = f'
' + html += f'
' + html += f'
{chevron_html} {role_icon} {role}
' + html += f'
{timestamp}
' + html += '
' + html += '
' + + # Tool Calls + tool_calls = msg.get("tool_calls") + if tool_calls: + for tc in tool_calls: + fn_name = tc.get("function", {}).get("name", "unknown") + args = tc.get("function", {}).get("arguments", "{}") + html += f''' +
+
+ {ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ')} + {ICON_WRENCH} Tool Call: {fn_name} +
+
+
{_escape_html(args)}
+
+
+ ''' + + # Content + if content: + if role == "tool": + html += f'
{_escape_html(content)}
' + else: + html += f'
{_escape_html(content)}
' + + # Reasoning + reasoning = msg.get("reasoning") or msg.get("reasoning_content") + if reasoning: + html += f''' +
+
+ {ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ')} + {ICON_SPARKLES} Reasoning +
+
+
{_escape_html(reasoning)}
+
+
+ ''' + + html += '
' + html += '
' + html_list.append(html) + return "\n".join(html_list) + +def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str: + if not sessions: + return "

No sessions to export.

" + + is_multi = len(sessions) > 1 + generated_at = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # Sidebar + sidebar_html = "" + if is_multi: + sidebar_items = [] + for s in sessions: + sid = s.get("id", "N/A") + title = s.get("title") or s.get("preview") or "Untitled Session" + if len(title) > 50: title = title[:47] + "..." + date = _format_timestamp(s.get("started_at", 0)).split(" ")[0] + + item = f''' + +
{_escape_html(title)}
+
+ {sid[:8]} + {date} +
+
+ ''' + sidebar_items.append(item) + + sidebar_html = f''' + + ''' + + # Main Content + sessions_html_list = [] + for s in sessions: + sid = s.get("id", "N/A") + title = s.get("title") or "Hermes Session" + model = s.get("model", "Unknown") + started_at = _format_timestamp(s.get("started_at", 0)) + messages = s.get("messages", []) + messages_html = _generate_messages_html(messages) + + view_class = "session-view" + if not is_multi: view_class += " active" + + session_html = f''' +
+
+

{_escape_html(title)}

+
+
ID: {sid}
+
Model: {_escape_html(model)}
+
Started: {started_at}
+
+
+
+ {messages_html} +
+
+ ''' + sessions_html_list.append(session_html) + + return HTML_TEMPLATE.format( + page_title="Hermes Session Export" if is_multi else _escape_html(sessions[0].get("title", "Hermes Session")), + sidebar_html=sidebar_html, + sessions_html="\n".join(sessions_html_list), + main_margin="var(--sidebar-width)" if is_multi else "0", + layout_class="layout-multi" if is_multi else "layout-single", + generated_at=generated_at + ) + +def generate_html_export(session_data: Dict[str, Any]) -> str: + """Legacy wrapper for single session export.""" + return generate_multi_session_html_export([session_data]) From 49dd0b1cb5f9e382097bc14f3b05529aeb2a2b11 Mon Sep 17 00:00:00 2001 From: doer <840004959@qq.com> Date: Sat, 23 May 2026 16:28:06 +0800 Subject: [PATCH 133/610] feat(cli): include system prompts in HTML export --- hermes_cli/session_export_html.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/hermes_cli/session_export_html.py b/hermes_cli/session_export_html.py index d11cb7912519..286e566e7023 100644 --- a/hermes_cli/session_export_html.py +++ b/hermes_cli/session_export_html.py @@ -18,6 +18,7 @@ ICON_WRENCH = ' @@ -272,6 +273,12 @@ HTML_TEMPLATE = """ border-left: 4px solid var(--accent-color); }} + .message-system {{ + background-color: var(--bg-color); + border-left: 4px solid var(--secondary-text); + opacity: 0.9; + }} + .message-tool {{ background-color: #f8fafc; border-style: dotted; @@ -286,6 +293,11 @@ HTML_TEMPLATE = """ background-color: #041c1c; }} + .message-system {{ + background-color: #020617; + border-left: 4px solid var(--secondary-text); + }} + .message-tool {{ background-color: #0f172a; }} @@ -599,6 +611,8 @@ def _generate_messages_html(messages: List[Dict[str, Any]]) -> str: role_icon = ICON_USER elif role == "assistant": role_icon = ICON_BOT + elif role == "system": + role_icon = ICON_SHIELD # Handle multimodal or complex content if isinstance(content, list): @@ -725,7 +739,22 @@ def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str: model = s.get("model", "Unknown") started_at = _format_timestamp(s.get("started_at", 0)) messages = s.get("messages", []) - messages_html = _generate_messages_html(messages) + + # System Prompt Handling: Prepend if present in session metadata and not already in messages + system_prompt = s.get("system_prompt") + all_messages = [] + if system_prompt: + # Avoid duplicating if the first message in the list is already the system prompt + first_msg = messages[0] if messages else None + if not (first_msg and first_msg.get("role") == "system" and first_msg.get("content") == system_prompt): + all_messages.append({ + "role": "system", + "content": system_prompt, + "timestamp": s.get("started_at", 0) + }) + all_messages.extend(messages) + + messages_html = _generate_messages_html(all_messages) view_class = "session-view" if not is_multi: view_class += " active" From a730156626b99af3af40284c8f5ec6a0f8e63d1b Mon Sep 17 00:00:00 2001 From: doer <840004959@qq.com> Date: Sat, 23 May 2026 16:34:38 +0800 Subject: [PATCH 134/610] feat(cli): redesign system prompt display as dedicated header section --- hermes_cli/session_export_html.py | 87 +++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 17 deletions(-) diff --git a/hermes_cli/session_export_html.py b/hermes_cli/session_export_html.py index 286e566e7023..1e3dc17b258f 100644 --- a/hermes_cli/session_export_html.py +++ b/hermes_cli/session_export_html.py @@ -480,6 +480,55 @@ HTML_TEMPLATE = """ display: block; }} + /* System Prompt Section */ + .system-prompt-section {{ + margin-top: 2rem; + border-radius: 0.75rem; + border: 1px solid var(--border-color); + background-color: var(--user-bg); + overflow: hidden; + text-align: left; + max-width: 800px; + margin-left: auto; + margin-right: auto; + }} + + .system-prompt-header {{ + padding: 0.75rem 1.25rem; + display: flex; + align-items: center; + gap: 0.6rem; + cursor: pointer; + user-select: none; + font-size: 0.875rem; + font-weight: 600; + color: var(--secondary-text); + }} + + .system-prompt-header:hover {{ + background-color: rgba(0,0,0,0.02); + }} + + .system-prompt-header svg.chevron {{ + transition: transform 0.2s ease; + }} + + .system-prompt-section.active svg.chevron {{ + transform: rotate(90deg); + }} + + .system-prompt-content {{ + display: none; + padding: 0 1.25rem 1.25rem 1.25rem; + font-size: 0.9rem; + border-top: 1px solid var(--border-color); + padding-top: 1rem; + }} + + .system-prompt-section.active .system-prompt-content {{ + display: block; + }} + footer {{ margin-top: 5rem; padding-top: 2rem; @@ -553,7 +602,7 @@ HTML_TEMPLATE = """ // Card Toggles document.addEventListener('click', function(e) {{ - const header = e.target.closest('.message-header, .tool-call-header, .reasoning-header'); + const header = e.target.closest('.message-header, .tool-call-header, .reasoning-header, .system-prompt-header'); if (header) {{ header.parentElement.classList.toggle('active'); }} @@ -740,27 +789,30 @@ def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str: started_at = _format_timestamp(s.get("started_at", 0)) messages = s.get("messages", []) - # System Prompt Handling: Prepend if present in session metadata and not already in messages - system_prompt = s.get("system_prompt") - all_messages = [] - if system_prompt: - # Avoid duplicating if the first message in the list is already the system prompt - first_msg = messages[0] if messages else None - if not (first_msg and first_msg.get("role") == "system" and first_msg.get("content") == system_prompt): - all_messages.append({ - "role": "system", - "content": system_prompt, - "timestamp": s.get("started_at", 0) - }) - all_messages.extend(messages) - - messages_html = _generate_messages_html(all_messages) + messages_html = _generate_messages_html(messages) view_class = "session-view" if not is_multi: view_class += " active" + session_view_id = f"view-{sid}" + + system_prompt = s.get("system_prompt") + system_html = "" + if system_prompt: + system_html = f''' +
+
+ {ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ')} + {ICON_SHIELD} System Prompt (Persona) +
+
+
{_escape_html(system_prompt)}
+
+
+ ''' + session_html = f''' -
+

{_escape_html(title)}

@@ -768,6 +820,7 @@ def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str:
Model: {_escape_html(model)}
Started: {started_at}
+ {system_html}
{messages_html} From 271130af56273620537a8461714c60cc00a2da2c Mon Sep 17 00:00:00 2001 From: doer <840004959@qq.com> Date: Sat, 23 May 2026 16:42:14 +0800 Subject: [PATCH 135/610] feat(cli): expand system prompt by default in HTML export --- hermes_cli/session_export_html.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes_cli/session_export_html.py b/hermes_cli/session_export_html.py index 1e3dc17b258f..09bfbc02948c 100644 --- a/hermes_cli/session_export_html.py +++ b/hermes_cli/session_export_html.py @@ -800,7 +800,7 @@ def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str: system_html = "" if system_prompt: system_html = f''' -
+
{ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ')} {ICON_SHIELD} System Prompt (Persona) From 4bd6fce1c1f70afb0fb2f75d7312e911d00b3c99 Mon Sep 17 00:00:00 2001 From: doer <840004959@qq.com> Date: Sat, 23 May 2026 16:53:05 +0800 Subject: [PATCH 136/610] fix(cli): fix layout width bug and ensure system prompt header is used --- hermes_cli/session_export_html.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/hermes_cli/session_export_html.py b/hermes_cli/session_export_html.py index 09bfbc02948c..9750041b69ef 100644 --- a/hermes_cli/session_export_html.py +++ b/hermes_cli/session_export_html.py @@ -228,9 +228,6 @@ HTML_TEMPLATE = """ margin: 0; }} - .layout-multi .main-content {{ - width: 0; - }} .session-view.active {{ display: block; }} From ab07e0652159ddad07f31ad998af036213a5f53f Mon Sep 17 00:00:00 2001 From: doer <840004959@qq.com> Date: Sat, 23 May 2026 16:57:36 +0800 Subject: [PATCH 137/610] style(export): restore width: 0 for multi-session flex layout --- hermes_cli/session_export_html.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hermes_cli/session_export_html.py b/hermes_cli/session_export_html.py index 9750041b69ef..95f058cbf2af 100644 --- a/hermes_cli/session_export_html.py +++ b/hermes_cli/session_export_html.py @@ -228,6 +228,10 @@ HTML_TEMPLATE = """ margin: 0; }} + .layout-multi .main-content {{ + width: 0; + }} + .session-view.active {{ display: block; }} From b172e03c200eef4a452cb38e7b83c0c6b26c88f0 Mon Sep 17 00:00:00 2001 From: doer <840004959@qq.com> Date: Sat, 23 May 2026 17:02:02 +0800 Subject: [PATCH 138/610] feat(cli): filter internal session_meta messages from HTML export --- hermes_cli/session_export_html.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/hermes_cli/session_export_html.py b/hermes_cli/session_export_html.py index 95f058cbf2af..6b3821ed03e8 100644 --- a/hermes_cli/session_export_html.py +++ b/hermes_cli/session_export_html.py @@ -652,6 +652,11 @@ def _generate_messages_html(messages: List[Dict[str, Any]]) -> str: html_list = [] for i, msg in enumerate(messages): role = msg.get("role", "unknown") + + # Skip internal metadata messages + if role == "session_meta": + continue + content = msg.get("content") or "" timestamp = _format_timestamp(msg.get("timestamp", 0)) From f76899facf8fc6d88da429adcf6f65be0494597e Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:14:29 -0700 Subject: [PATCH 139/610] feat(sessions): wire html + prompt-only formats into 'sessions export' Salvage follow-up integrating PR #30481 (@simplast) and PR #57683 (@catbearlove1-lang) into the unified export surface: - --format html: standalone self-contained HTML transcript (single session or multi-session with sidebar), works with all shared filters and --redact; requires a file output path. - --only user-prompts: prompt-only export (jsonl records or md sections) via the shared session_export renderer; the separate export-prompts subcommand from the original PR is subsumed by this flag. - AUTHOR_MAP entries for both contributors; docs EN + zh-Hans. --- hermes_cli/main.py | 103 +++++++++++++++++- scripts/release.py | 3 + tests/hermes_cli/test_session_export.py | 93 +--------------- website/docs/user-guide/sessions.md | 26 +++++ .../current/user-guide/sessions.md | 26 +++++ 5 files changed, 162 insertions(+), 89 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c7120483a4b1..3593d78fdbc0 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13543,10 +13543,18 @@ def main(): ) sessions_export.add_argument( "--format", - choices=["jsonl", "md", "qmd"], + choices=["jsonl", "md", "qmd", "html"], default="jsonl", help="Export format (default: jsonl)", ) + sessions_export.add_argument( + "--only", + choices=["user-prompts"], + help=( + "Export only a filtered view (user-prompts: one prompt record " + "per line for jsonl, headed sections for md)" + ), + ) sessions_export.add_argument( "--session-id", help="Session ID or unique prefix to export" ) @@ -13788,6 +13796,99 @@ def main(): return redact_session_data(data) + def _collect_sessions(): + """Resolve --session-id / filters / bare export into a list + of redacted session dicts, or None after printing an error.""" + if args.session_id: + resolved = db.resolve_session_id(args.session_id) + data = _redact(db.export_session(resolved)) if resolved else None + if not data: + print(f"Session '{args.session_id}' not found.") + return None + return [data] + if filters: + candidates = db.list_prune_candidates(**filters) + if args.dry_run: + print( + f"Would export {len(candidates)} session(s) " + f"({describe_filters(filters)})." + ) + for row in candidates[:100]: + print(f" {row.get('id')} {row.get('source', '')}") + if len(candidates) > 100: + print(f" ... {len(candidates) - 100} more") + return None + return [ + s + for s in ( + _redact(db.export_session(row["id"])) for row in candidates + ) + if s + ] + if args.dry_run: + print("--dry-run requires at least one filter.") + return None + return [_redact(s) for s in db.export_all(source=None)] + + # Prompt-only export (--only user-prompts): one prompt record per + # line (jsonl) or headed sections (md). Delegates rendering to + # hermes_cli.session_export. + if getattr(args, "only", None): + if args.format not in ("jsonl", "md"): + print("--only user-prompts supports --format jsonl or md.") + return + from hermes_cli.session_export import ( + export_record_count, + render_sessions_export, + ) + + sessions = _collect_sessions() + if sessions is None: + db.close() + return + rendered = render_sessions_export( + sessions, + fmt="markdown" if args.format == "md" else "jsonl", + only=args.only, + ) + if not args.output or args.output == "-": + sys.stdout.write(rendered) + db.close() + return + with open(args.output, "w", encoding="utf-8") as f: + f.write(rendered) + count, noun = export_record_count(sessions, only=args.only) + suffix = "" if count == 1 else "s" + print(f"Exported {count} {noun}{suffix} to {args.output}") + db.close() + return + + # Standalone HTML export: one self-contained file (single session + # or multi-session with sidebar navigation). + if args.format == "html": + if not args.output or args.output == "-": + print("HTML export requires an output file path.") + return + from hermes_cli.session_export_html import ( + generate_html_export, + generate_multi_session_html_export, + ) + + sessions = _collect_sessions() + if sessions is None: + db.close() + return + if len(sessions) == 1: + content = generate_html_export(sessions[0]) + else: + content = generate_multi_session_html_export(sessions) + with open(args.output, "w", encoding="utf-8") as f: + f.write(content) + suffix = "" if len(sessions) == 1 else "s" + print(f"Exported {len(sessions)} session{suffix} to {args.output} (HTML)") + db.close() + return + if args.format == "jsonl": if not args.output: print("JSONL export requires an output path (use - for stdout).") diff --git a/scripts/release.py b/scripts/release.py index 65001e564fb4..2b4c3d2199b0 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -839,6 +839,9 @@ AUTHOR_MAP = { "27719690+Mirac1eSky@users.noreply.github.com": "Mirac1eSky", "web3blind@users.noreply.github.com": "web3blind", "264741654+web3blind@users.noreply.github.com": "web3blind", + # Sessions export format cluster (July 2026): HTML + prompt-only salvages + "840004959@qq.com": "simplast", + "catbearlove1@gmail.com": "catbearlove1-lang", "julia@alexland.us": "alexg0bot", "christian@scheid.tech": "scheidti", # Moonshot schema anyOf+enum salvage (May 2026) diff --git a/tests/hermes_cli/test_session_export.py b/tests/hermes_cli/test_session_export.py index c737ccd68614..10bb4b0435c5 100644 --- a/tests/hermes_cli/test_session_export.py +++ b/tests/hermes_cli/test_session_export.py @@ -188,7 +188,7 @@ def test_sessions_export_cli_prompt_only_markdown_file(monkeypatch, capsys, tmp_ "--session-id", "sess", "--format", - "markdown", + "md", "--only", "user-prompts", ], @@ -203,13 +203,13 @@ def test_sessions_export_cli_prompt_only_markdown_file(monkeypatch, capsys, tmp_ assert "I will inspect the auth middleware." not in content -def test_sessions_export_rejects_full_session_markdown(monkeypatch, capsys): +def test_sessions_export_only_rejects_unsupported_format(monkeypatch, capsys): import hermes_cli.main as main_mod import hermes_state class FakeDB: def export_all(self, source=None): - return [_sample_session()] + raise AssertionError("should refuse before touching the DB") def close(self): pass @@ -218,92 +218,9 @@ def test_sessions_export_rejects_full_session_markdown(monkeypatch, capsys): monkeypatch.setattr( sys, "argv", - ["hermes", "sessions", "export", "-", "--format", "markdown"], + ["hermes", "sessions", "export", "-", "--format", "html", "--only", "user-prompts"], ) main_mod.main() - assert ( - "Error: --format markdown currently requires --only user-prompts." - in capsys.readouterr().out - ) - - -def test_sessions_export_prompts_cli_stdout(monkeypatch, capsys): - import hermes_cli.main as main_mod - import hermes_state - - captured = {} - - class FakeDB: - def resolve_session_id(self, session_id): - captured["resolved_from"] = session_id - return "sess-123" - - def export_session(self, session_id): - captured["exported"] = session_id - return _sample_session() - - def close(self): - captured["closed"] = True - - monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) - monkeypatch.setattr( - sys, - "argv", - ["hermes", "sessions", "export-prompts", "sess"], - ) - - main_mod.main() - - output = capsys.readouterr().out - records = [json.loads(line) for line in output.splitlines()] - assert [record["text"] for record in records] == [ - "Why is login broken?", - "Only show me the prompts.", - ] - assert captured == { - "resolved_from": "sess", - "exported": "sess-123", - "closed": True, - } - - -def test_sessions_export_prompts_cli_markdown_file(monkeypatch, capsys, tmp_path): - import hermes_cli.main as main_mod - import hermes_state - - class FakeDB: - def resolve_session_id(self, _session_id): - return "sess-123" - - def export_session(self, _session_id): - return _sample_session() - - def close(self): - pass - - output_path = tmp_path / "prompts.md" - monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) - monkeypatch.setattr( - sys, - "argv", - [ - "hermes", - "sessions", - "export-prompts", - "sess", - "--format", - "md", - "--output", - str(output_path), - ], - ) - - main_mod.main() - - assert f"Exported 2 prompts to {output_path}" in capsys.readouterr().out - content = output_path.read_text(encoding="utf-8") - assert "# User prompts for session sess-123" in content - assert "Why is login broken?" in content - assert "I will inspect the auth middleware." not in content + assert "--only user-prompts supports --format jsonl or md." in capsys.readouterr().out diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index fdbea52cbed0..e2ae75ea7d4a 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -312,6 +312,32 @@ Exported files contain one JSON object per line with full session metadata and a `export` accepts the same filters as `prune` / `archive` — `--older-than` / `--newer-than` / `--before` / `--after` (durations like `5h`/`2d`/`1w`, bare days, or ISO timestamps), `--source`, `--title`, `--model`, `--provider`, `--cwd`, `--min-messages` / `--max-messages`, `--min-tokens` / `--max-tokens`, `--min-cost` / `--max-cost`, `--min-tool-calls` / `--max-tool-calls`, `--user`, `--chat-id`, `--chat-type`, `--branch`, and `--end-reason`. Add `--dry-run` to preview which sessions match without writing anything. Note: bulk filters match *ended* sessions; unfiltered `export` dumps everything, including active ones. +### Export Sessions to HTML + +`--format html` writes a single self-contained HTML file — no remote dependencies — with styled message bubbles, collapsible tool output, and (for multi-session exports) a sidebar to switch between sessions: + +```bash +# One session as a standalone HTML page +hermes sessions export --format html --session-id 20250305_091523_a1b2c3d4 transcript.html + +# All Telegram sessions from the last week in one file, secrets redacted +hermes sessions export --format html --newer-than 1w --source telegram --redact archive.html +``` + +### Export Only Your Prompts + +`--only user-prompts` exports just the prompts you wrote — no assistant replies, tool output, or system context. Useful for building prompt libraries or reviewing what you asked: + +```bash +# One JSONL record per prompt (session id, index, timestamp, text) +hermes sessions export prompts.jsonl --session-id 20250305_091523_a1b2c3d4 --only user-prompts + +# Markdown, straight to stdout +hermes sessions export - --session-id 20250305_091523_a1b2c3d4 --only user-prompts --format md +``` + +Works with `--format jsonl` (default) or `md`, honors the same filters for bulk export, and combines with `--redact`. + ### Export Sessions to Markdown/QMD Pass `--format md` or `--format qmd` when you want a readable, file-based archive before hiding or deleting old sessions. Markdown/QMD exports write one file per session into a directory (default: `~/.hermes/session-exports`). diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md index 3b0da17196e0..78c8d58be7e1 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md @@ -289,6 +289,32 @@ hermes sessions export backup.jsonl --redact `export` 接受与 `prune` / `archive` 相同的过滤器 — `--older-than` / `--newer-than` / `--before` / `--after`(时长如 `5h`/`2d`/`1w`、纯数字天数或 ISO 时间戳)、`--source`、`--title`、`--model`、`--provider`、`--cwd`、`--min-messages` / `--max-messages`、`--min-tokens` / `--max-tokens`、`--min-cost` / `--max-cost`、`--min-tool-calls` / `--max-tool-calls`、`--user`、`--chat-id`、`--chat-type`、`--branch` 和 `--end-reason`。加 `--dry-run` 可预览匹配的 session 而不写入任何内容。注意:带过滤器的批量导出只匹配*已结束*的 session;不带过滤器的 `export` 会导出所有 session(包括活跃的)。 +### 导出 Session 为 HTML + +`--format html` 生成一个完全独立的 HTML 文件 — 无远程依赖 — 带样式化的消息气泡、可折叠的工具输出,多 session 导出时还带侧边栏导航: + +```bash +# 将一个 session 导出为独立 HTML 页面 +hermes sessions export --format html --session-id 20250305_091523_a1b2c3d4 transcript.html + +# 将最近一周的所有 Telegram session 导出到一个文件,并脱敏 +hermes sessions export --format html --newer-than 1w --source telegram --redact archive.html +``` + +### 只导出你的 Prompt + +`--only user-prompts` 只导出你写的 prompt — 不含助手回复、工具输出或系统上下文。适合构建 prompt 库或回顾你问过什么: + +```bash +# 每个 prompt 一条 JSONL 记录(session id、序号、时间戳、文本) +hermes sessions export prompts.jsonl --session-id 20250305_091523_a1b2c3d4 --only user-prompts + +# Markdown 格式,直接输出到 stdout +hermes sessions export - --session-id 20250305_091523_a1b2c3d4 --only user-prompts --format md +``` + +支持 `--format jsonl`(默认)或 `md`,批量导出时同样支持全部过滤器,也可与 `--redact` 组合。 + ### 导出 Session 为 Markdown/QMD 当你想在隐藏或删除旧 session 之前保留一份可读的文件归档时,传入 `--format md` 或 `--format qmd`。Markdown/QMD 导出会为每个 session 写入一个文件到目录中(默认:`~/.hermes/session-exports`)。 From e0176cbd47a186d3117282a14d8e36529023b000 Mon Sep 17 00:00:00 2001 From: alex107ivanov <30668368+alex107ivanov@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:14:33 -0700 Subject: [PATCH 140/610] feat(discord): optionally mention approval owners on exec prompts Opt-in discord.approval_mentions (config.yaml, bridged to DISCORD_APPROVAL_MENTIONS) prepends <@id> mentions for numeric allowlist entries to exec-approval prompts, with a scoped AllowedMentions override (users only). Default off - no surprise pings. Reapplied onto the content-mirror layout from #60245: mentions prepend to the visible content block and its truncation budget. Original implementation from PR #39719; commits arrived bot-authored, re-attributed to the contributor. --- cli-config.yaml.example | 236 ++---------------- hermes_cli/config.py | 5 + plugins/platforms/discord/adapter.py | 42 +++- .../gateway/test_discord_approval_mentions.py | 87 +++++++ 4 files changed, 148 insertions(+), 222 deletions(-) create mode 100644 tests/gateway/test_discord_approval_mentions.py diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 5e4bc2331771..33de62259067 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -13,7 +13,7 @@ model: # Inference provider selection: # "auto" - Auto-detect from credentials (default) # "openrouter" - OpenRouter (requires: OPENROUTER_API_KEY or OPENAI_API_KEY) - # "nous" - Nous Portal OAuth (requires: hermes auth add nous) + # "nous" - Nous Portal OAuth (requires: hermes login) # "nous-api" - Nous Portal API key (requires: NOUS_API_KEY) # "anthropic" - Direct Anthropic API (requires: ANTHROPIC_API_KEY) # "openai-codex" - OpenAI Codex (requires: hermes auth) @@ -85,25 +85,6 @@ model: # # default_headers: # User-Agent: "curl/8.7.1" - # - # extra_headers: accepted as an alias of default_headers (merged, with - # extra_headers winning when both are set) — matches the per-provider - # extra_headers key below. - # - # Per-provider variant: named providers / custom_providers entries accept an - # extra_headers dict scoped to that endpoint only — for reverse proxies, - # gateways, or custom auth (e.g. Cloudflare Access service tokens). - # Merged onto SDK/provider defaults with the entry's values winning. - # Header values are treated as secrets and are never logged. - # - # providers: - # my-proxy: - # base_url: "https://llm.internal.example.com/v1" - # key_env: "MY_PROXY_API_KEY" - # extra_headers: - # CF-Access-Client-Id: "xxxx.access" - # CF-Access-Client-Secret: "${CF_ACCESS_SECRET}" - # X-Client-Name: "hermes-agent" # Named provider overrides (optional) # Use this for per-provider request timeouts, non-stream stale timeouts, @@ -117,9 +98,7 @@ model: # ``stale_timeout_seconds`` controls the non-streaming stale-call detector and # wins over the legacy HERMES_API_CALL_STALE_TIMEOUT env var. Leaving these # unset keeps the legacy defaults (HERMES_API_TIMEOUT=1800s, -# HERMES_API_CALL_STALE_TIMEOUT=90s, native Anthropic 900s). The -# implicit non-stream stale detector is auto-disabled for local endpoints -# and can scale upward for very large contexts. +# HERMES_API_CALL_STALE_TIMEOUT=300s, native Anthropic 900s). # # Not currently wired for AWS Bedrock (bedrock_converse + AnthropicBedrock # SDK paths) — those use boto3 with its own timeout configuration. @@ -185,16 +164,6 @@ model: # # worktree: true # Always create a worktree when in a git repo # worktree: false # Default — only create when -w flag is passed -# -# By default a new worktree branches from the freshly-fetched remote tip -# (the current branch's upstream, else the remote's default branch) so it -# starts current with the project instead of from the local clone's -# (possibly stale) HEAD. Set worktree_sync: false to branch from local HEAD -# instead — useful when offline or when you deliberately want the clone's -# exact current state as the base. -# -# worktree_sync: true # Default — branch from the fetched remote tip -# worktree_sync: false # Branch from local HEAD (offline / pinned base) # ============================================================================= # Terminal Tool Configuration @@ -213,11 +182,6 @@ terminal: backend: "local" cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise. timeout: 180 - # HOME policy for tool subprocesses: - # auto - default: host uses your real HOME; containers use HERMES_HOME/home - # real - force your real OS-user HOME - # profile - force HERMES_HOME/home for strict per-profile CLI config isolation - home_mode: "auto" docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into Docker /workspace. lifetime_seconds: 300 # sudo_password: "hunter2" # Optional: pipe a sudo password via sudo -S. SECURITY WARNING: plaintext. @@ -410,11 +374,7 @@ compression: # Trigger compression at this % of model's context limit (default: 0.50 = 50%) # Lower values = more aggressive compression, higher values = compress later threshold: 0.50 - - # Existing Codex gpt-5.5 behavior: raise Hermes' compaction trigger to 85% - # for the ChatGPT Codex OAuth route. Set false to opt back down to threshold. - codex_gpt55_autoraise: true - + # Fraction of the threshold to preserve as recent tail (default: 0.20 = 20%) # e.g. 20% of 50% threshold = 10% of total context kept as recent messages. # Summary output is separately capped at 12K tokens (Gemini output limit). @@ -426,14 +386,6 @@ compression: # compression of older turns. protect_last_n: 20 - # Codex app-server (codex CLI runtime) thread-compaction mode. The codex - # agent owns the real thread context on this runtime, so Hermes' summarizer - # cannot shrink it — compaction goes through the app server instead. - # native = let Codex decide when to compact its own thread (default) - # hermes = let Hermes threshold trigger Codex thread/compact/start - # off = Hermes will not auto-trigger compaction; Codex may still compact natively - codex_app_server_auto: native - # Number of non-system messages to protect at the head of the transcript, in # ADDITION to the system prompt (which is always implicitly protected). # Head messages are NEVER summarized — they survive every compression @@ -482,7 +434,7 @@ prompt_caching: # Provider options: # "auto" - Best available: OpenRouter → Nous Portal → main endpoint (default) # "openrouter" - Force OpenRouter (requires OPENROUTER_API_KEY) -# "nous" - Force Nous Portal (requires: hermes auth add nous) +# "nous" - Force Nous Portal (requires: hermes login) # "gemini" - Force Google AI Studio direct (requires: GOOGLE_API_KEY or GEMINI_API_KEY) # "ollama-cloud" - Ollama Cloud (requires: OLLAMA_API_KEY) # "codex" - Force Codex OAuth (requires: hermes model → Codex). @@ -526,10 +478,6 @@ prompt_caching: # # reasoning controls: # # extra_body: # # enable_thinking: false -# # Some vLLM/Qwen deployments expect this nested: -# # extra_body: -# # chat_template_kwargs: -# # enable_thinking: false # ============================================================================= # Persistent Memory @@ -602,41 +550,6 @@ max_concurrent_sessions: null # explicitly want one shared "room brain" per group/channel. group_sessions_per_user: true -# ───────────────────────────────────────────────────────────────────────────── -# API Server — per-client model routing -# ───────────────────────────────────────────────────────────────────────────── -# Route different API clients to different models/providers on a single -# Hermes deployment. Clients choose a backend by sending a specific string -# as the OpenAI ``model`` field. Unmapped model values fall back to the -# global model configured in the ``model:`` section above, and an explicit -# session /model override always wins over a route. -# -# Configure via the ``platforms.api_server.extra.model_routes`` gateway -# config block: -# -# platforms: -# api_server: -# enabled: true -# extra: -# key: "your-api-server-secret" -# model_routes: -# # Xiaozhi clients send model="minimax-m2" → routed to MiniMax via OpenRouter -# minimax-m2: -# model: "minimax/minimax-m1" -# provider: "openrouter" # optional — overrides global provider -# # api_key: "sk-..." # optional — per-route UPSTREAM provider -# # key (NOT caller auth; never logged) -# # base_url: "https://..." # optional — per-route base URL -# # GPT clients keep their own alias -# gpt-5: -# model: "openai/gpt-5" -# provider: "openrouter" -# -# Configured aliases are automatically listed by GET /v1/models so clients -# can discover them without manual coordination. Caller authentication is -# unchanged: every request still authenticates with the global API server -# key (``extra.key`` / API_SERVER_KEY). - # ───────────────────────────────────────────────────────────────────────────── # Gateway Streaming # ───────────────────────────────────────────────────────────────────────────── @@ -692,10 +605,10 @@ agent: # gateway_timeout_warning: 900 # Graceful drain timeout for gateway stop/restart (seconds). - # Default 0 = no drain: a restart interrupts in-flight agents immediately, - # cleans up, and exits. Set a positive value only if you want a grace - # window on /restart, and keep it well under systemd's TimeoutStopSec. - # restart_drain_timeout: 0 + # The gateway stops accepting new work, waits for in-flight agents to + # finish, then interrupts anything still running after this timeout. + # 0 = no drain, interrupt immediately. + # restart_drain_timeout: 60 # Max app-level retry attempts for API errors (connection drops, provider # timeouts, 5xx, etc.) before the agent surfaces the failure. Lower this @@ -703,34 +616,7 @@ agent: # primaries (default 3). The OpenAI SDK does its own low-level retries # underneath this wrapper — this is the Hermes-level loop. # api_max_retries: 3 - - # After the agent edits code without fresh passing verification, nudge it to - # verify before finishing. The default "auto" enables it on interactive - # coding surfaces (CLI, TUI, desktop) and programmatic callers, and disables - # it on conversational messaging surfaces (Telegram, Discord, etc.) where the - # verification summary would reach a human as chat noise. Set true or false to - # force it on or off; the HERMES_VERIFY_ON_STOP env var (1/0) takes precedence. - # verify_on_stop: auto - - # Standing operator instructions for the coding posture (when Hermes is in a - # code workspace). Appended to the coding brief as an extra system block, so - # you can pin project-wide workflow rules without editing the shipped brief. - # Accepts a string or a list of strings. Takes effect next session. - # coding_instructions: - # - "For UI work, don't run tsc/lint until I approve the look." - # - "Clean the diff before you commit and push." - - # When verify-on-stop finds edited code without fresh verification evidence, - # append guidance for creative UI work (avoid broad tsc/lint/test before visual - # approval) and clean-diff expectations. Set false to keep that nudge terse. - # verify_guidance: true - - # A `pre_verify` hook (plugin or shell, see Event Hooks docs) can keep the - # agent going one more turn to verify/clean before finishing. This caps how - # many times one turn may be nudged to continue, so a hook can't trap the loop. - # Default 3. - # max_verify_nudges: 3 - + # Enable verbose logging verbose: false @@ -833,19 +719,6 @@ platform_toolsets: # # allowed_chats: ["-1001234567890"] # extra: # disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages -# rich_messages: false # Bot API 10.1 rich messages (tables/task lists/details/math); default false for copyable legacy MarkdownV2, set true to opt in -# rich_drafts: false # Experimental rich draft previews during Telegram DM streaming; default false because Telegram Desktop/macOS can visually overlay draft frames -# command_menu: -# # Telegram allows up to 100 BotCommands; Hermes defaults to 60 so -# # all built-in commands plus common skill commands stay visible -# # while remaining under Telegram's payload-size limit. Clamped 1..100. -# max_commands: 60 -# # prepend = user priority first, then Hermes defaults -# # append = Hermes defaults first, then user priority -# # replace = only the list below defines priority -# priority_mode: prepend -# priority: -# - my_plugin_command # # Discord-specific settings (config.yaml top-level, not under platforms:): # @@ -856,6 +729,7 @@ platform_toolsets: # reactions: true # Show processing reactions (default: true) # history_backfill: true # Recover missed channel messages on mention (default: true) # history_backfill_limit: 50 # Max messages to scan backwards (default: 50) +# approval_mentions: false # Mention numeric allowed users on approval prompts (default: false) # ───────────────────────────────────────────────────────────────────────────── # Available toolsets (use these names in platform_toolsets or the toolsets list) @@ -876,6 +750,7 @@ platform_toolsets: # image_gen - image_generate (requires FAL_KEY) # skills - skills_list, skill_view # skills_hub - skill_hub (search/install/manage from online registries — user-driven only) +# moa - mixture_of_agents (requires OPENROUTER_API_KEY) # todo - todo (in-memory task planning, no deps) # tts - text_to_speech (Edge TTS free, or ELEVENLABS/OPENAI/MINIMAX/MISTRAL key) # cronjob - cronjob (create/list/update/pause/resume/run/remove scheduled tasks) @@ -890,7 +765,7 @@ platform_toolsets: # # COMPOSITE: # debugging - terminal + web + file -# safe - web + vision (no terminal access) +# safe - web + vision + moa (no terminal access) # all - Everything available # # web - Web search and content extraction (web_search, web_extract) @@ -901,6 +776,7 @@ platform_toolsets: # vision - Image analysis (vision_analyze) # image_gen - Image generation with FLUX (image_generate) # skills - Load skill documents (skills_list, skill_view) +# moa - Mixture of Agents reasoning (mixture_of_agents) # todo - Task planning and tracking for multi-step work # memory - Persistent memory across sessions (personal notes + user profile) # session_search - Search and recall past conversations (FTS5 + Gemini Flash summarization) @@ -909,7 +785,7 @@ platform_toolsets: # # Composite toolsets: # debugging - terminal + web + file (for troubleshooting) -# safe - web + vision (no terminal access) +# safe - web + vision + moa (no terminal access) # NOTE: The top-level "toolsets" key is deprecated and ignored. # Tool configuration is managed per-platform via platform_toolsets above. @@ -922,7 +798,7 @@ platform_toolsets: # ============================================================================= # Connect to external MCP servers to add tools from the MCP ecosystem. # Each server's tools are automatically discovered and registered. -# See website/docs/user-guide/features/mcp.md for full documentation. +# See docs/mcp.md for full documentation. # # Stdio servers (spawn a subprocess): # command: the executable to run @@ -936,10 +812,6 @@ platform_toolsets: # Optional per-server settings: # timeout: tool call timeout in seconds (default: 120) # connect_timeout: initial connection timeout (default: 60) -# keepalive_interval: liveness ping cadence in seconds (default: 180). -# Lower it below the server's session TTL for servers that expire idle -# sessions quickly (e.g. Unreal Engine editor MCP, ~15s), otherwise idle -# tool calls hit an expired session and pay a slow reconnect. Floored at 5s. # # mcp_servers: # time: @@ -1088,7 +960,6 @@ display: # new: Show a tool indicator only when the tool changes (skip repeats) # all: Show every tool call with a short preview (default) # verbose: Full args, results, and debug logs (same as /verbose) - # log: Silent in chat; append every tool call to ~/.hermes/logs/tool_calls.log (gateway only) # Toggle at runtime with /verbose in the CLI tool_progress: all @@ -1362,80 +1233,3 @@ updates: # # default — works on Fly.io out of the box). # # # # public_url: "https://example.com/hermes" -# -# ----------------------------------------------------------------------------- -# Self-hosted OIDC dashboard auth (generic OpenID Connect — Authentik, -# Keycloak, Zitadel, Authelia, Auth0, Okta, Google, …). Use this INSTEAD of the -# nous block above when gating the dashboard with your own identity provider. -# Each setting can be overridden by an environment variable: -# -# dashboard.oauth.self_hosted.issuer <- HERMES_DASHBOARD_OIDC_ISSUER -# dashboard.oauth.self_hosted.client_id <- HERMES_DASHBOARD_OIDC_CLIENT_ID -# dashboard.oauth.self_hosted.scopes <- HERMES_DASHBOARD_OIDC_SCOPES -# dashboard.oauth.self_hosted.client_secret <- HERMES_DASHBOARD_OIDC_CLIENT_SECRET -# -# dashboard: -# oauth: -# provider: self-hosted -# self_hosted: -# issuer: "https://auth.example.com/application/o/hermes/" # required -# client_id: "hermes-dashboard" # required -# scopes: "openid profile email" # optional -# -# # OPTIONAL — set ONLY if your IDP registered the client as -# # *confidential* (Authentik / Keycloak often default to this). When -# # set, Hermes authenticates the client at the token endpoint -# # (client_secret_basic or client_secret_post, auto-selected from the -# # IDP's discovery doc) IN ADDITION to PKCE. Leave unset for a public -# # (PKCE-only) client — the common case. -# # -# # This is a CREDENTIAL: prefer setting HERMES_DASHBOARD_OIDC_CLIENT_SECRET -# # in ~/.hermes/.env over putting it here in config.yaml. -# # client_secret: "" - -# ============================================================================= -# External secret sources -# ============================================================================= -# Pull provider credentials from external secret managers at process startup -# instead of storing them in ~/.hermes/.env. Only the manager's bootstrap -# credential (e.g. BWS_ACCESS_TOKEN / OP_SERVICE_ACCOUNT_TOKEN) lives in .env -# (or your shell / desktop session); everything else rotates centrally. -# Failures never block startup — Hermes warns once and continues with -# whatever .env already had. -# -# Multiple sources can be enabled at once: -# - "mapped" sources (explicit VAR -> ref bindings, e.g. 1Password's env: -# map) beat "bulk" sources (whole-project dumps like Bitwarden BSM) -# - within a shape, the first source to claim a var wins; later claims -# are skipped with a startup warning (never a silent clobber) -# - a source's override_existing lets it beat .env/shell values, but -# never another secret source's claim -# Docs: https://hermes-agent.nousresearch.com/docs/user-guide/secrets/ -# -# secrets: -# # Optional explicit ordering of enabled sources. -# # sources: [onepassword, bitwarden] -# -# # ---- Bitwarden Secrets Manager (bws CLI) -------------------------------- -# bitwarden: -# enabled: false -# access_token_env: BWS_ACCESS_TOKEN # bootstrap token, sourced from .env -# project_id: "" # UUID of the BSM project to sync -# server_url: "" # "" = US Cloud; EU/self-hosted URL otherwise -# cache_ttl_seconds: 300 # 0 disables caching -# override_existing: true # BSM values win over existing env -# auto_install: true # lazy-download bws into ~/.hermes/bin -# -# # ---- 1Password (op CLI) ------------------------------------------------- -# onepassword: -# enabled: false -# # Map env-var names to op:// secret references. Each is resolved with a -# # single `op read` at startup. -# env: -# OPENAI_API_KEY: "op://Private/OpenAI/api key" -# ANTHROPIC_API_KEY: "op://Private/Anthropic/credential" -# account: "" # op --account shorthand; "" = default -# service_account_token_env: OP_SERVICE_ACCOUNT_TOKEN # headless auth; unset = desktop session -# binary_path: "" # "" = resolve op via PATH; else absolute path -# cache_ttl_seconds: 300 # 0 disables BOTH cache layers -# override_existing: true # resolved values win over existing env diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7f2945de5f80..184350ae3a47 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2384,6 +2384,11 @@ DEFAULT_CONFIG = { # real memory cost. Default 32 MiB matches the historical hardcoded # cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES. "max_attachment_bytes": 33554432, + # When True, Discord approval prompts mention numeric allowed users so + # owners notice approval requests in shared channels/threads. Env + # override: DISCORD_APPROVAL_MENTIONS. Default false avoids surprise + # pings. + "approval_mentions": False, # Voice-channel audio effects (the continuous mixer). OFF by default. # When enabled, the bot installs a software mixer on the outgoing voice # stream so a low ambient "thinking" bed, verbal acknowledgements, and diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 47e93a2836cc..8a83f8805ed9 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -754,6 +754,13 @@ _DISCORD_PROMPT_TIMEOUT_MIN = 30 _DISCORD_PROMPT_TIMEOUT_MAX = 900 +def _env_bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name, "").strip().lower() + if not raw: + return default + return raw in {"true", "1", "yes", "on"} + + def _read_discord_prompt_timeout() -> int: """Return the timeout (in seconds) for Discord button views. @@ -5502,6 +5509,20 @@ class DiscordAdapter(BasePlatformAdapter): body = body[: max(0, budget - len(truncated_suffix))] + truncated_suffix return f"{prefix}{body}{suffix}" + def _approval_mention_content(self) -> Optional[str]: + """Return user mentions for approval prompts when explicitly enabled. + + Gated on ``discord.approval_mentions`` in config.yaml (bridged to the + ``DISCORD_APPROVAL_MENTIONS`` env var). Only numeric allowlist entries + can be mentioned; default off avoids surprise pings. + """ + if not _env_bool("DISCORD_APPROVAL_MENTIONS", False): + return None + user_ids = sorted(uid for uid in self._allowed_user_ids if str(uid).isdigit()) + if not user_ids: + return None + return " ".join(f"<@{uid}>" for uid in user_ids) + async def send_exec_approval( self, chat_id: str, command: str, session_key: str, description: str = "dangerous command", @@ -5541,6 +5562,9 @@ class DiscordAdapter(BasePlatformAdapter): "Do you want Hermes to run this command?\n\n" "**Requested command:**\n```bash\n" ) + mention_content = self._approval_mention_content() + if mention_content: + prompt_prefix = f"{mention_content}\n{prompt_prefix}" prompt_tail = f"\n```\n**Reason:** {reason_display}" truncated_suffix = "\n... [truncated]" command_budget = max(0, self.MAX_MESSAGE_LENGTH - len(prompt_prefix) - len(prompt_tail)) @@ -5576,7 +5600,17 @@ class DiscordAdapter(BasePlatformAdapter): admin_user_ids=admin_user_ids, ) - msg = await channel.send(content=content, embed=embed, view=view) + send_kwargs: Dict[str, Any] = {"content": content, "embed": embed, "view": view} + if mention_content: + allowed_mentions_cls = getattr(discord, "AllowedMentions", None) + if allowed_mentions_cls is not None: + send_kwargs["allowed_mentions"] = allowed_mentions_cls( + users=True, + roles=False, + everyone=False, + replied_user=False, + ) + msg = await channel.send(**send_kwargs) view._message = msg # store for on_timeout expiration editing return SendResult(success=True, message_id=str(msg.id)) @@ -8161,6 +8195,12 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: if isinstance(allowed_users_cfg, list): allowed_users_cfg = ",".join(str(v) for v in allowed_users_cfg) os.environ["DISCORD_ALLOWED_USERS"] = str(allowed_users_cfg) + approval_mentions_cfg = ( + discord_cfg["approval_mentions"] if "approval_mentions" in discord_cfg + else platform_extra_cfg.get("approval_mentions") + ) + if approval_mentions_cfg is not None and not os.getenv("DISCORD_APPROVAL_MENTIONS"): + os.environ["DISCORD_APPROVAL_MENTIONS"] = str(approval_mentions_cfg).lower() frc = discord_cfg.get("free_response_channels") if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"): if isinstance(frc, list): diff --git a/tests/gateway/test_discord_approval_mentions.py b/tests/gateway/test_discord_approval_mentions.py new file mode 100644 index 000000000000..786ecace3429 --- /dev/null +++ b/tests/gateway/test_discord_approval_mentions.py @@ -0,0 +1,87 @@ +"""Discord approval prompts can opt into owner mentions.""" + +import os +from types import SimpleNamespace + +import pytest + +from plugins.platforms.discord.adapter import ( + DiscordAdapter, + _apply_yaml_config, +) + + +class _FakeChannel: + def __init__(self): + self.sent_kwargs = None + + async def send(self, **kwargs): + self.sent_kwargs = kwargs + return SimpleNamespace(id=12345) + + +class _FakeClient: + def __init__(self, channel): + self.channel = channel + + def get_channel(self, channel_id): + return self.channel + + +@pytest.mark.asyncio +async def test_exec_approval_mentions_allowed_users_when_enabled(monkeypatch): + monkeypatch.setenv("DISCORD_APPROVAL_MENTIONS", "true") + channel = _FakeChannel() + adapter = object.__new__(DiscordAdapter) + adapter._client = _FakeClient(channel) + adapter._allowed_user_ids = {"222", "111", "alice"} + adapter._allowed_role_ids = set() + adapter.config = SimpleNamespace(extra=None) + + result = await adapter.send_exec_approval( + chat_id="99", + command="make check", + session_key="session-1", + description="dangerous command", + ) + + assert result.success is True + # Mentions are prepended to the (always present) content mirror. + assert channel.sent_kwargs["content"].startswith("<@111> <@222>\n") + assert "make check" in channel.sent_kwargs["content"] + assert "allowed_mentions" in channel.sent_kwargs + assert channel.sent_kwargs["embed"].title.endswith("Command Approval Required") + + +@pytest.mark.asyncio +async def test_exec_approval_does_not_mention_by_default(monkeypatch): + monkeypatch.delenv("DISCORD_APPROVAL_MENTIONS", raising=False) + channel = _FakeChannel() + adapter = object.__new__(DiscordAdapter) + adapter._client = _FakeClient(channel) + adapter._allowed_user_ids = {"111"} + adapter._allowed_role_ids = set() + adapter.config = SimpleNamespace(extra=None) + + result = await adapter.send_exec_approval( + chat_id="99", + command="make check", + session_key="session-1", + ) + + assert result.success is True + # Content mirror is always present (embed-invisibility fix), but no + # mention markup and no allowed_mentions override. + assert "<@" not in channel.sent_kwargs["content"] + assert "allowed_mentions" not in channel.sent_kwargs + + +def test_yaml_config_bridges_approval_mentions_to_env(monkeypatch): + monkeypatch.delenv("DISCORD_APPROVAL_MENTIONS", raising=False) + + _apply_yaml_config( + {"discord": {"approval_mentions": True}}, + {"approval_mentions": True}, + ) + + assert os.environ["DISCORD_APPROVAL_MENTIONS"] == "true" From 94b4ac118aa1244f456c3b6d491a450157af67ba Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:14:50 -0700 Subject: [PATCH 141/610] chore: add alex107ivanov to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 2b4c3d2199b0..3b0c9845c358 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -291,6 +291,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "30668368+alex107ivanov@users.noreply.github.com": "alex107ivanov", "210088133+rungmc357@users.noreply.github.com": "rungmc357", "florian.rutishauser@outlook.com": "flo1t", "fanyang@microsoft.com": "fanyangCS", From b1500af27738c80aebe0d615325ad39e86a1807f Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:17:57 -0700 Subject: [PATCH 142/610] fix: restore cli-config.yaml.example from main (stale-branch version leaked into salvage) --- cli-config.yaml.example | 236 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 221 insertions(+), 15 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 33de62259067..5e4bc2331771 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -13,7 +13,7 @@ model: # Inference provider selection: # "auto" - Auto-detect from credentials (default) # "openrouter" - OpenRouter (requires: OPENROUTER_API_KEY or OPENAI_API_KEY) - # "nous" - Nous Portal OAuth (requires: hermes login) + # "nous" - Nous Portal OAuth (requires: hermes auth add nous) # "nous-api" - Nous Portal API key (requires: NOUS_API_KEY) # "anthropic" - Direct Anthropic API (requires: ANTHROPIC_API_KEY) # "openai-codex" - OpenAI Codex (requires: hermes auth) @@ -85,6 +85,25 @@ model: # # default_headers: # User-Agent: "curl/8.7.1" + # + # extra_headers: accepted as an alias of default_headers (merged, with + # extra_headers winning when both are set) — matches the per-provider + # extra_headers key below. + # + # Per-provider variant: named providers / custom_providers entries accept an + # extra_headers dict scoped to that endpoint only — for reverse proxies, + # gateways, or custom auth (e.g. Cloudflare Access service tokens). + # Merged onto SDK/provider defaults with the entry's values winning. + # Header values are treated as secrets and are never logged. + # + # providers: + # my-proxy: + # base_url: "https://llm.internal.example.com/v1" + # key_env: "MY_PROXY_API_KEY" + # extra_headers: + # CF-Access-Client-Id: "xxxx.access" + # CF-Access-Client-Secret: "${CF_ACCESS_SECRET}" + # X-Client-Name: "hermes-agent" # Named provider overrides (optional) # Use this for per-provider request timeouts, non-stream stale timeouts, @@ -98,7 +117,9 @@ model: # ``stale_timeout_seconds`` controls the non-streaming stale-call detector and # wins over the legacy HERMES_API_CALL_STALE_TIMEOUT env var. Leaving these # unset keeps the legacy defaults (HERMES_API_TIMEOUT=1800s, -# HERMES_API_CALL_STALE_TIMEOUT=300s, native Anthropic 900s). +# HERMES_API_CALL_STALE_TIMEOUT=90s, native Anthropic 900s). The +# implicit non-stream stale detector is auto-disabled for local endpoints +# and can scale upward for very large contexts. # # Not currently wired for AWS Bedrock (bedrock_converse + AnthropicBedrock # SDK paths) — those use boto3 with its own timeout configuration. @@ -164,6 +185,16 @@ model: # # worktree: true # Always create a worktree when in a git repo # worktree: false # Default — only create when -w flag is passed +# +# By default a new worktree branches from the freshly-fetched remote tip +# (the current branch's upstream, else the remote's default branch) so it +# starts current with the project instead of from the local clone's +# (possibly stale) HEAD. Set worktree_sync: false to branch from local HEAD +# instead — useful when offline or when you deliberately want the clone's +# exact current state as the base. +# +# worktree_sync: true # Default — branch from the fetched remote tip +# worktree_sync: false # Branch from local HEAD (offline / pinned base) # ============================================================================= # Terminal Tool Configuration @@ -182,6 +213,11 @@ terminal: backend: "local" cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise. timeout: 180 + # HOME policy for tool subprocesses: + # auto - default: host uses your real HOME; containers use HERMES_HOME/home + # real - force your real OS-user HOME + # profile - force HERMES_HOME/home for strict per-profile CLI config isolation + home_mode: "auto" docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into Docker /workspace. lifetime_seconds: 300 # sudo_password: "hunter2" # Optional: pipe a sudo password via sudo -S. SECURITY WARNING: plaintext. @@ -374,7 +410,11 @@ compression: # Trigger compression at this % of model's context limit (default: 0.50 = 50%) # Lower values = more aggressive compression, higher values = compress later threshold: 0.50 - + + # Existing Codex gpt-5.5 behavior: raise Hermes' compaction trigger to 85% + # for the ChatGPT Codex OAuth route. Set false to opt back down to threshold. + codex_gpt55_autoraise: true + # Fraction of the threshold to preserve as recent tail (default: 0.20 = 20%) # e.g. 20% of 50% threshold = 10% of total context kept as recent messages. # Summary output is separately capped at 12K tokens (Gemini output limit). @@ -386,6 +426,14 @@ compression: # compression of older turns. protect_last_n: 20 + # Codex app-server (codex CLI runtime) thread-compaction mode. The codex + # agent owns the real thread context on this runtime, so Hermes' summarizer + # cannot shrink it — compaction goes through the app server instead. + # native = let Codex decide when to compact its own thread (default) + # hermes = let Hermes threshold trigger Codex thread/compact/start + # off = Hermes will not auto-trigger compaction; Codex may still compact natively + codex_app_server_auto: native + # Number of non-system messages to protect at the head of the transcript, in # ADDITION to the system prompt (which is always implicitly protected). # Head messages are NEVER summarized — they survive every compression @@ -434,7 +482,7 @@ prompt_caching: # Provider options: # "auto" - Best available: OpenRouter → Nous Portal → main endpoint (default) # "openrouter" - Force OpenRouter (requires OPENROUTER_API_KEY) -# "nous" - Force Nous Portal (requires: hermes login) +# "nous" - Force Nous Portal (requires: hermes auth add nous) # "gemini" - Force Google AI Studio direct (requires: GOOGLE_API_KEY or GEMINI_API_KEY) # "ollama-cloud" - Ollama Cloud (requires: OLLAMA_API_KEY) # "codex" - Force Codex OAuth (requires: hermes model → Codex). @@ -478,6 +526,10 @@ prompt_caching: # # reasoning controls: # # extra_body: # # enable_thinking: false +# # Some vLLM/Qwen deployments expect this nested: +# # extra_body: +# # chat_template_kwargs: +# # enable_thinking: false # ============================================================================= # Persistent Memory @@ -550,6 +602,41 @@ max_concurrent_sessions: null # explicitly want one shared "room brain" per group/channel. group_sessions_per_user: true +# ───────────────────────────────────────────────────────────────────────────── +# API Server — per-client model routing +# ───────────────────────────────────────────────────────────────────────────── +# Route different API clients to different models/providers on a single +# Hermes deployment. Clients choose a backend by sending a specific string +# as the OpenAI ``model`` field. Unmapped model values fall back to the +# global model configured in the ``model:`` section above, and an explicit +# session /model override always wins over a route. +# +# Configure via the ``platforms.api_server.extra.model_routes`` gateway +# config block: +# +# platforms: +# api_server: +# enabled: true +# extra: +# key: "your-api-server-secret" +# model_routes: +# # Xiaozhi clients send model="minimax-m2" → routed to MiniMax via OpenRouter +# minimax-m2: +# model: "minimax/minimax-m1" +# provider: "openrouter" # optional — overrides global provider +# # api_key: "sk-..." # optional — per-route UPSTREAM provider +# # key (NOT caller auth; never logged) +# # base_url: "https://..." # optional — per-route base URL +# # GPT clients keep their own alias +# gpt-5: +# model: "openai/gpt-5" +# provider: "openrouter" +# +# Configured aliases are automatically listed by GET /v1/models so clients +# can discover them without manual coordination. Caller authentication is +# unchanged: every request still authenticates with the global API server +# key (``extra.key`` / API_SERVER_KEY). + # ───────────────────────────────────────────────────────────────────────────── # Gateway Streaming # ───────────────────────────────────────────────────────────────────────────── @@ -605,10 +692,10 @@ agent: # gateway_timeout_warning: 900 # Graceful drain timeout for gateway stop/restart (seconds). - # The gateway stops accepting new work, waits for in-flight agents to - # finish, then interrupts anything still running after this timeout. - # 0 = no drain, interrupt immediately. - # restart_drain_timeout: 60 + # Default 0 = no drain: a restart interrupts in-flight agents immediately, + # cleans up, and exits. Set a positive value only if you want a grace + # window on /restart, and keep it well under systemd's TimeoutStopSec. + # restart_drain_timeout: 0 # Max app-level retry attempts for API errors (connection drops, provider # timeouts, 5xx, etc.) before the agent surfaces the failure. Lower this @@ -616,7 +703,34 @@ agent: # primaries (default 3). The OpenAI SDK does its own low-level retries # underneath this wrapper — this is the Hermes-level loop. # api_max_retries: 3 - + + # After the agent edits code without fresh passing verification, nudge it to + # verify before finishing. The default "auto" enables it on interactive + # coding surfaces (CLI, TUI, desktop) and programmatic callers, and disables + # it on conversational messaging surfaces (Telegram, Discord, etc.) where the + # verification summary would reach a human as chat noise. Set true or false to + # force it on or off; the HERMES_VERIFY_ON_STOP env var (1/0) takes precedence. + # verify_on_stop: auto + + # Standing operator instructions for the coding posture (when Hermes is in a + # code workspace). Appended to the coding brief as an extra system block, so + # you can pin project-wide workflow rules without editing the shipped brief. + # Accepts a string or a list of strings. Takes effect next session. + # coding_instructions: + # - "For UI work, don't run tsc/lint until I approve the look." + # - "Clean the diff before you commit and push." + + # When verify-on-stop finds edited code without fresh verification evidence, + # append guidance for creative UI work (avoid broad tsc/lint/test before visual + # approval) and clean-diff expectations. Set false to keep that nudge terse. + # verify_guidance: true + + # A `pre_verify` hook (plugin or shell, see Event Hooks docs) can keep the + # agent going one more turn to verify/clean before finishing. This caps how + # many times one turn may be nudged to continue, so a hook can't trap the loop. + # Default 3. + # max_verify_nudges: 3 + # Enable verbose logging verbose: false @@ -719,6 +833,19 @@ platform_toolsets: # # allowed_chats: ["-1001234567890"] # extra: # disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages +# rich_messages: false # Bot API 10.1 rich messages (tables/task lists/details/math); default false for copyable legacy MarkdownV2, set true to opt in +# rich_drafts: false # Experimental rich draft previews during Telegram DM streaming; default false because Telegram Desktop/macOS can visually overlay draft frames +# command_menu: +# # Telegram allows up to 100 BotCommands; Hermes defaults to 60 so +# # all built-in commands plus common skill commands stay visible +# # while remaining under Telegram's payload-size limit. Clamped 1..100. +# max_commands: 60 +# # prepend = user priority first, then Hermes defaults +# # append = Hermes defaults first, then user priority +# # replace = only the list below defines priority +# priority_mode: prepend +# priority: +# - my_plugin_command # # Discord-specific settings (config.yaml top-level, not under platforms:): # @@ -729,7 +856,6 @@ platform_toolsets: # reactions: true # Show processing reactions (default: true) # history_backfill: true # Recover missed channel messages on mention (default: true) # history_backfill_limit: 50 # Max messages to scan backwards (default: 50) -# approval_mentions: false # Mention numeric allowed users on approval prompts (default: false) # ───────────────────────────────────────────────────────────────────────────── # Available toolsets (use these names in platform_toolsets or the toolsets list) @@ -750,7 +876,6 @@ platform_toolsets: # image_gen - image_generate (requires FAL_KEY) # skills - skills_list, skill_view # skills_hub - skill_hub (search/install/manage from online registries — user-driven only) -# moa - mixture_of_agents (requires OPENROUTER_API_KEY) # todo - todo (in-memory task planning, no deps) # tts - text_to_speech (Edge TTS free, or ELEVENLABS/OPENAI/MINIMAX/MISTRAL key) # cronjob - cronjob (create/list/update/pause/resume/run/remove scheduled tasks) @@ -765,7 +890,7 @@ platform_toolsets: # # COMPOSITE: # debugging - terminal + web + file -# safe - web + vision + moa (no terminal access) +# safe - web + vision (no terminal access) # all - Everything available # # web - Web search and content extraction (web_search, web_extract) @@ -776,7 +901,6 @@ platform_toolsets: # vision - Image analysis (vision_analyze) # image_gen - Image generation with FLUX (image_generate) # skills - Load skill documents (skills_list, skill_view) -# moa - Mixture of Agents reasoning (mixture_of_agents) # todo - Task planning and tracking for multi-step work # memory - Persistent memory across sessions (personal notes + user profile) # session_search - Search and recall past conversations (FTS5 + Gemini Flash summarization) @@ -785,7 +909,7 @@ platform_toolsets: # # Composite toolsets: # debugging - terminal + web + file (for troubleshooting) -# safe - web + vision + moa (no terminal access) +# safe - web + vision (no terminal access) # NOTE: The top-level "toolsets" key is deprecated and ignored. # Tool configuration is managed per-platform via platform_toolsets above. @@ -798,7 +922,7 @@ platform_toolsets: # ============================================================================= # Connect to external MCP servers to add tools from the MCP ecosystem. # Each server's tools are automatically discovered and registered. -# See docs/mcp.md for full documentation. +# See website/docs/user-guide/features/mcp.md for full documentation. # # Stdio servers (spawn a subprocess): # command: the executable to run @@ -812,6 +936,10 @@ platform_toolsets: # Optional per-server settings: # timeout: tool call timeout in seconds (default: 120) # connect_timeout: initial connection timeout (default: 60) +# keepalive_interval: liveness ping cadence in seconds (default: 180). +# Lower it below the server's session TTL for servers that expire idle +# sessions quickly (e.g. Unreal Engine editor MCP, ~15s), otherwise idle +# tool calls hit an expired session and pay a slow reconnect. Floored at 5s. # # mcp_servers: # time: @@ -960,6 +1088,7 @@ display: # new: Show a tool indicator only when the tool changes (skip repeats) # all: Show every tool call with a short preview (default) # verbose: Full args, results, and debug logs (same as /verbose) + # log: Silent in chat; append every tool call to ~/.hermes/logs/tool_calls.log (gateway only) # Toggle at runtime with /verbose in the CLI tool_progress: all @@ -1233,3 +1362,80 @@ updates: # # default — works on Fly.io out of the box). # # # # public_url: "https://example.com/hermes" +# +# ----------------------------------------------------------------------------- +# Self-hosted OIDC dashboard auth (generic OpenID Connect — Authentik, +# Keycloak, Zitadel, Authelia, Auth0, Okta, Google, …). Use this INSTEAD of the +# nous block above when gating the dashboard with your own identity provider. +# Each setting can be overridden by an environment variable: +# +# dashboard.oauth.self_hosted.issuer <- HERMES_DASHBOARD_OIDC_ISSUER +# dashboard.oauth.self_hosted.client_id <- HERMES_DASHBOARD_OIDC_CLIENT_ID +# dashboard.oauth.self_hosted.scopes <- HERMES_DASHBOARD_OIDC_SCOPES +# dashboard.oauth.self_hosted.client_secret <- HERMES_DASHBOARD_OIDC_CLIENT_SECRET +# +# dashboard: +# oauth: +# provider: self-hosted +# self_hosted: +# issuer: "https://auth.example.com/application/o/hermes/" # required +# client_id: "hermes-dashboard" # required +# scopes: "openid profile email" # optional +# +# # OPTIONAL — set ONLY if your IDP registered the client as +# # *confidential* (Authentik / Keycloak often default to this). When +# # set, Hermes authenticates the client at the token endpoint +# # (client_secret_basic or client_secret_post, auto-selected from the +# # IDP's discovery doc) IN ADDITION to PKCE. Leave unset for a public +# # (PKCE-only) client — the common case. +# # +# # This is a CREDENTIAL: prefer setting HERMES_DASHBOARD_OIDC_CLIENT_SECRET +# # in ~/.hermes/.env over putting it here in config.yaml. +# # client_secret: "" + +# ============================================================================= +# External secret sources +# ============================================================================= +# Pull provider credentials from external secret managers at process startup +# instead of storing them in ~/.hermes/.env. Only the manager's bootstrap +# credential (e.g. BWS_ACCESS_TOKEN / OP_SERVICE_ACCOUNT_TOKEN) lives in .env +# (or your shell / desktop session); everything else rotates centrally. +# Failures never block startup — Hermes warns once and continues with +# whatever .env already had. +# +# Multiple sources can be enabled at once: +# - "mapped" sources (explicit VAR -> ref bindings, e.g. 1Password's env: +# map) beat "bulk" sources (whole-project dumps like Bitwarden BSM) +# - within a shape, the first source to claim a var wins; later claims +# are skipped with a startup warning (never a silent clobber) +# - a source's override_existing lets it beat .env/shell values, but +# never another secret source's claim +# Docs: https://hermes-agent.nousresearch.com/docs/user-guide/secrets/ +# +# secrets: +# # Optional explicit ordering of enabled sources. +# # sources: [onepassword, bitwarden] +# +# # ---- Bitwarden Secrets Manager (bws CLI) -------------------------------- +# bitwarden: +# enabled: false +# access_token_env: BWS_ACCESS_TOKEN # bootstrap token, sourced from .env +# project_id: "" # UUID of the BSM project to sync +# server_url: "" # "" = US Cloud; EU/self-hosted URL otherwise +# cache_ttl_seconds: 300 # 0 disables caching +# override_existing: true # BSM values win over existing env +# auto_install: true # lazy-download bws into ~/.hermes/bin +# +# # ---- 1Password (op CLI) ------------------------------------------------- +# onepassword: +# enabled: false +# # Map env-var names to op:// secret references. Each is resolved with a +# # single `op read` at startup. +# env: +# OPENAI_API_KEY: "op://Private/OpenAI/api key" +# ANTHROPIC_API_KEY: "op://Private/Anthropic/credential" +# account: "" # op --account shorthand; "" = default +# service_account_token_env: OP_SERVICE_ACCOUNT_TOKEN # headless auth; unset = desktop session +# binary_path: "" # "" = resolve op via PATH; else absolute path +# cache_ttl_seconds: 300 # 0 disables BOTH cache layers +# override_existing: true # resolved values win over existing env From b4289200ba93fad96a2e1e87270e036613b14293 Mon Sep 17 00:00:00 2001 From: Que0x Date: Sat, 4 Jul 2026 04:18:00 +0300 Subject: [PATCH 143/610] fix(web-server): close OAuth token TOCTOU by writing 0o600 atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_save_anthropic_oauth_creds` wrote the Anthropic OAuth token file with `os.replace(tmp, path)` followed by a post-hoc `chmod(0o600)`. Between the rename and the chmod the token file existed at the default umask (0o644 on most hosts) — a window in which another local user could read the access/refresh tokens. Write via `utils.atomic_json_write(..., mode=0o600)`, which creates the temp with mode 0o600 *before* any content is written, fsyncs, atomically replaces, preserves the existing file's owner, and cleans up its temp on failure. This matches the `atomic_json_write(mode=0o600)` call already used elsewhere in this module for the credential-pool write, and #56644's owner preservation. Tests updated for the new mechanism, plus a check that the write goes through `atomic_json_write(mode=0o600)` (mutation-verified). --- hermes_cli/web_server.py | 29 +++++----------- .../hermes_cli/test_web_server_oauth_write.py | 34 +++++++++++++++---- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8232ab77c0a5..d7ff480b390d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -7050,26 +7050,15 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a "refreshToken": refresh_token, "expiresAt": expires_at_ms, } - oauth_file.parent.mkdir(parents=True, exist_ok=True) - tmp_path = oauth_file.with_name( - f"{oauth_file.name}.tmp.{os.getpid()}.{secrets.token_hex(8)}" - ) - try: - with tmp_path.open("w", encoding="utf-8") as handle: - handle.write(json.dumps(payload, indent=2)) - handle.flush() - os.fsync(handle.fileno()) - os.replace(tmp_path, oauth_file) - try: - oauth_file.chmod(stat.S_IRUSR | stat.S_IWUSR) - except OSError: - pass - finally: - try: - if tmp_path.exists(): - tmp_path.unlink() - except OSError: - pass + # atomic_json_write creates the temp with mode 0o600 (via mkstemp) *before* + # any content is written, then fsyncs and atomically replaces the target. + # The previous os.replace + post-hoc chmod left a TOCTOU window in which the + # OAuth token file was world-readable at the default umask (0o644 on most + # hosts) between the rename and the chmod. atomic_json_write also preserves + # the existing file's owner and cleans up its temp on failure. + from utils import atomic_json_write + + atomic_json_write(oauth_file, payload, indent=2, mode=0o600) # Best-effort credential-pool insert. Failure here doesn't invalidate # the file write — pool registration only matters for the rotation # strategy, not for runtime credential resolution. diff --git a/tests/hermes_cli/test_web_server_oauth_write.py b/tests/hermes_cli/test_web_server_oauth_write.py index 20200b9a73b2..4289c781e1f5 100644 --- a/tests/hermes_cli/test_web_server_oauth_write.py +++ b/tests/hermes_cli/test_web_server_oauth_write.py @@ -36,18 +36,40 @@ def test_dashboard_oauth_write_uses_owner_only_permissions(oauth_file): assert mode == 0o600 -def test_dashboard_oauth_write_uses_atomic_replace_and_cleans_temp_files(oauth_file, monkeypatch): - replace_calls = [] +def test_dashboard_oauth_write_is_atomic_and_cleans_temp_on_failure(oauth_file, monkeypatch): + """If the atomic replace fails, no partial file or temp file is left.""" + import utils def flaky_replace(src, dst): - replace_calls.append((src, dst)) raise OSError('simulated replace failure') - monkeypatch.setattr('hermes_cli.web_server.os.replace', flaky_replace) + monkeypatch.setattr(utils, 'atomic_replace', flaky_replace) with pytest.raises(OSError, match='simulated replace failure'): _save_anthropic_oauth_creds('access-token', 'refresh-token', 123456) - assert replace_calls, 'helper should attempt atomic os.replace()' assert not oauth_file.exists() - assert not list(oauth_file.parent.glob(f'{oauth_file.name}.tmp*')) + # atomic_json_write stages to ``._*.tmp`` and unlinks it on failure. + assert not list(oauth_file.parent.glob('*.tmp')) + + +def test_dashboard_oauth_write_uses_atomic_json_write_with_owner_only_mode(oauth_file, monkeypatch): + """The OAuth token file must be written 0o600 from creation via + ``atomic_json_write(mode=0o600)``, so it is never briefly world-readable + (the old ``os.replace`` + post-hoc ``chmod`` TOCTOU).""" + import utils + + calls = {} + real = utils.atomic_json_write + + def spy(path, data, **kwargs): + calls['mode'] = kwargs.get('mode') + return real(path, data, **kwargs) + + monkeypatch.setattr(utils, 'atomic_json_write', spy) + + _save_anthropic_oauth_creds('access-token', 'refresh-token', 123456) + + assert calls.get('mode') == 0o600, \ + 'OAuth creds must be written 0o600 atomically (no chmod-after-replace window)' + assert oauth_file.exists() From 5e51b123f32b7f6a51fbd5759e89ba5146ce4003 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:16:48 -0700 Subject: [PATCH 144/610] feat(mem0): add self-hosted mode to the setup wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The salvaged SelfHostedBackend made self-hosted servers reachable via mem0.json / MEM0_HOST, but the setup wizard still offered only Platform and OSS — exactly the gap users hit (Discord report: 'At memory setup there's only 2 options'). Adds a third wizard mode: - interactive picker: Platform / Self-hosted server / Open Source - non-interactive: hermes memory setup mem0 --mode selfhosted --host http://... [--api-key ...] [--dry-run] - host -> mem0.json (behavioral), API key -> .env as MEM0_API_KEY (secret), optional key for AUTH_DISABLED servers - best-effort reachability check against the server, non-fatal - README + memory-providers docs updated with the wizard path --- plugins/memory/mem0/README.md | 8 +- plugins/memory/mem0/_setup.py | 113 +++++++++++++++++- tests/plugins/memory/test_mem0_setup.py | 46 +++++++ .../user-guide/features/memory-providers.md | 12 +- 4 files changed, 172 insertions(+), 7 deletions(-) diff --git a/plugins/memory/mem0/README.md b/plugins/memory/mem0/README.md index 9232f0c0c286..c5c39f2bc49f 100644 --- a/plugins/memory/mem0/README.md +++ b/plugins/memory/mem0/README.md @@ -42,7 +42,13 @@ The plugin has three connection modes: Connect the plugin to a standalone Mem0 server you run yourself — the Docker-shipped Mem0 dashboard/server with its own REST API. Unlike OSS mode (which runs `mem0ai` in-process with your own vector store), here the plugin just talks HTTP to your server. 1. Run the Mem0 server (FastAPI + pgvector) from its Docker image and note its URL and `ADMIN_API_KEY`. -2. Point the plugin at it — either via env vars: +2. Point the plugin at it — via the setup wizard: + ```bash + hermes memory setup # select "mem0" → "Self-hosted server" + # Or non-interactive: + hermes memory setup mem0 --mode selfhosted --host http://localhost:8888 --api-key your-admin-api-key + ``` + or via env vars: ```bash echo "MEM0_HOST=http://localhost:8888" >> ~/.hermes/.env echo "MEM0_API_KEY=your-admin-api-key" >> ~/.hermes/.env diff --git a/plugins/memory/mem0/_setup.py b/plugins/memory/mem0/_setup.py index c81e5d1f0293..a331ef3a80ea 100644 --- a/plugins/memory/mem0/_setup.py +++ b/plugins/memory/mem0/_setup.py @@ -67,6 +67,7 @@ def parse_flags(argv: list[str] | None = None) -> dict[str, str]: flags: dict[str, str] = { "mode": "", "api_key": "", + "host": "", "oss_llm": "openai", "oss_llm_key": "", "oss_llm_model": "", @@ -90,6 +91,7 @@ def parse_flags(argv: list[str] | None = None) -> dict[str, str]: flag_map = { "--mode": "mode", "--api-key": "api_key", + "--host": "host", "--oss-llm": "oss_llm", "--oss-llm-key": "oss_llm_key", "--oss-llm-model": "oss_llm_model", @@ -331,6 +333,102 @@ def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> No print("\n Start a new session to activate.\n") +def _check_selfhosted_server(host: str) -> None: + """Best-effort reachability check for a self-hosted Mem0 server (non-fatal).""" + import urllib.error + import urllib.request as _urlreq + + try: + req = _urlreq.Request(f"{host.rstrip('/')}/docs", method="GET") + _urlreq.urlopen(req, timeout=5) + print(f" ✓ Mem0 server reachable at {host}") + except urllib.error.HTTPError: + # Any HTTP response (401/403/404) still means something is listening. + print(f" ✓ Mem0 server responding at {host}") + except Exception: + print(f" ⚠ Could not reach {host} — check the URL and that the server is running.") + + +def _setup_selfhosted(hermes_home: str, config: dict, flags: dict[str, str]) -> None: + """Self-hosted mode setup — point at an existing Mem0 dashboard server. + + For users already running the Dockerized Mem0 FastAPI server: stores the + server URL (behavioral -> mem0.json) and an optional API key + (secret -> .env as MEM0_API_KEY). + """ + existing_config = {} + config_path = Path(hermes_home) / "mem0.json" + if config_path.exists(): + try: + existing_config = json.loads(config_path.read_text()) + except Exception: + pass + + provider_config = dict(existing_config) + + print("\n Configuring mem0 (self-hosted server):\n") + + host = flags.get("host") or _prompt( + "Mem0 server URL (e.g. http://localhost:8888)", + default=provider_config.get("host") or None, + ) + if not host: + print(" Error: a server URL is required for self-hosted mode.", file=sys.stderr) + return + host = host.rstrip("/") + + env_writes: dict[str, str] = {} + if flags.get("api_key"): + env_writes["MEM0_API_KEY"] = flags["api_key"] + else: + existing_key = os.environ.get("MEM0_API_KEY", "") + if existing_key: + masked = f"...{existing_key[-4:]}" if len(existing_key) > 4 else "set" + val = _prompt(f"Server API key (current: {masked}, blank to keep)", secret=True) + else: + val = _prompt("Server API key (blank if AUTH_DISABLED)", secret=True) + if val: + env_writes["MEM0_API_KEY"] = val + + user_id = flags.get("user_id") or _prompt( + "User identifier", default=provider_config.get("user_id") or "hermes-user" + ) + agent_id = _prompt("Agent identifier", default=provider_config.get("agent_id") or "hermes") + + if flags.get("dry_run"): + print(f"\n [dry-run] Would save config: host={host}, user_id={user_id}, agent_id={agent_id}") + if env_writes: + print(" [dry-run] Would write API key to .env") + _check_selfhosted_server(host) + print(" [dry-run] No files written.\n") + return + + provider_config["mode"] = "platform" # routing: oss > host > platform; host wins + provider_config["host"] = host + provider_config["user_id"] = user_id + provider_config["agent_id"] = agent_id + + from hermes_cli.config import save_config + config["memory"]["provider"] = "mem0" + save_config(config) + + from plugins.memory.mem0 import Mem0MemoryProvider + provider = Mem0MemoryProvider() + provider.save_config(provider_config, hermes_home) + + if env_writes: + _write_env(Path(hermes_home) / ".env", env_writes) + + _check_selfhosted_server(host) + print("\n Memory provider: mem0 (self-hosted)") + print(f" Server: {host}") + print(" Activation saved to config.yaml") + print(" Provider config saved") + if env_writes: + print(" API key saved to .env") + print("\n Start a new session to activate.\n") + + def _setup_oss(hermes_home: str, config: dict, flags: dict[str, str]) -> None: """OSS mode setup — build config from flags or interactive prompts. @@ -846,10 +944,10 @@ def _check_min_dep_version() -> None: def post_setup(hermes_home: str, config: dict) -> None: """Entry point called by hermes memory setup framework. - Only intercepts when OSS mode is requested (via --mode oss flag or - interactive picker). For platform mode, returns without action so the - framework's schema-based flow handles it (preserving the original - platform onboarding experience). + Routes on --mode (platform / selfhosted / oss); with no flag it shows an + interactive picker with all three modes. Platform keeps the framework's + original schema-based onboarding; selfhosted points at an existing Mem0 + server; oss builds a local SDK config. """ _check_min_dep_version() flags = parse_flags(sys.argv[1:]) @@ -859,6 +957,10 @@ def post_setup(hermes_home: str, config: dict) -> None: _setup_oss(hermes_home, config, flags) return + if flags["mode"] in ("selfhosted", "self-hosted"): + _setup_selfhosted(hermes_home, config, flags) + return + if flags["mode"] == "platform": _setup_platform(hermes_home, config, flags) return @@ -866,10 +968,13 @@ def post_setup(hermes_home: str, config: dict) -> None: # No --mode flag: show interactive picker mode_items = [ ("Platform", "Mem0 Cloud API (lightweight, just needs an API key)"), + ("Self-hosted server", "Connect to an existing self-hosted Mem0 server (Docker/FastAPI)"), ("Open Source", "Run Mem0 locally (self-hosted LLM + vector store)"), ] mode_idx = _curses_select(" Select mode", mode_items, 0) if mode_idx == 1: + _setup_selfhosted(hermes_home, config, flags) + elif mode_idx == 2: flags["_mode_from_flag"] = False _setup_oss(hermes_home, config, flags) else: diff --git a/tests/plugins/memory/test_mem0_setup.py b/tests/plugins/memory/test_mem0_setup.py index b365970c56e5..b4b85a58638c 100644 --- a/tests/plugins/memory/test_mem0_setup.py +++ b/tests/plugins/memory/test_mem0_setup.py @@ -209,6 +209,52 @@ class TestPostSetup: assert mem0_json["mode"] == "oss" assert mem0_json["oss"]["llm"]["provider"] == "openai" + def test_selfhosted_flag_mode(self, tmp_path, monkeypatch): + monkeypatch.setattr("sys.argv", [ + "hermes", "--mode", "selfhosted", + "--host", "http://localhost:8888/", "--api-key", "admin-key", + ]) + monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path) + _inject_fake_hermes_cli(monkeypatch) + monkeypatch.setattr("plugins.memory.mem0._setup._check_selfhosted_server", lambda h: None) + config = {"memory": {}} + post_setup(str(tmp_path), config) + assert config["memory"]["provider"] == "mem0" + env_content = (tmp_path / ".env").read_text() + assert "MEM0_API_KEY=admin-key" in env_content + mem0_json = json.loads((tmp_path / "mem0.json").read_text()) + assert mem0_json["host"] == "http://localhost:8888" # trailing slash stripped + assert mem0_json["user_id"] == "hermes-user" + + def test_selfhosted_no_api_key_auth_disabled(self, tmp_path, monkeypatch): + # AUTH_DISABLED servers need no key — setup must not write one. + monkeypatch.setattr("sys.argv", [ + "hermes", "--mode", "self-hosted", "--host", "http://mem0.lan:8888", + ]) + monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path) + monkeypatch.delenv("MEM0_API_KEY", raising=False) + _inject_fake_hermes_cli(monkeypatch) + monkeypatch.setattr("plugins.memory.mem0._setup._check_selfhosted_server", lambda h: None) + config = {"memory": {}} + post_setup(str(tmp_path), config) + assert not (tmp_path / ".env").exists() + mem0_json = json.loads((tmp_path / "mem0.json").read_text()) + assert mem0_json["host"] == "http://mem0.lan:8888" + + def test_selfhosted_dry_run_no_files(self, tmp_path, monkeypatch): + monkeypatch.setattr("sys.argv", [ + "hermes", "--mode", "selfhosted", + "--host", "http://localhost:8888", "--api-key", "k", "--dry-run", + ]) + monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path) + _inject_fake_hermes_cli(monkeypatch) + monkeypatch.setattr("plugins.memory.mem0._setup._check_selfhosted_server", lambda h: None) + config = {"memory": {}} + post_setup(str(tmp_path), config) + assert not (tmp_path / ".env").exists() + assert not (tmp_path / "mem0.json").exists() + assert "provider" not in config["memory"] + class TestDryRun: diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index ea8aec293bdf..1c6ebf7c43f3 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -348,7 +348,15 @@ Preview without writing files: hermes memory setup mem0 --mode oss --oss-llm-key sk-... --dry-run ``` -**Setup (Self-Hosted Dashboard):** connect to a Mem0 server you run via Docker (the dashboard's REST API). Set `host` and an API key — either as env vars: +**Setup (Self-Hosted Dashboard):** connect to a Mem0 server you run via Docker (the dashboard's REST API): + +```bash +hermes memory setup # select "mem0" → "Self-hosted server" +# Or via flags: +hermes memory setup mem0 --mode selfhosted --host http://localhost:8888 --api-key your-admin-api-key +``` + +Or configure manually — either as env vars: ```bash echo "MEM0_HOST=http://localhost:8888" >> ~/.hermes/.env @@ -381,7 +389,7 @@ The plugin authenticates with `X-API-Key` and uses the server's `/search` / `/me | Embedder | openai, ollama | | Vector Store | qdrant (local/server), pgvector | -**Switching modes:** Re-run `hermes memory setup mem0 --mode ` or edit `mem0.json` directly. +**Switching modes:** Re-run `hermes memory setup mem0 --mode ` or edit `mem0.json` directly. --- From 0e04d14209d944a3e99aa3ae934628f3048257c7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:12:49 -0700 Subject: [PATCH 145/610] feat(sessions): trace export + HF upload via 'sessions export --format trace' (#60507) * feat(trace): upload sessions to HF Agent Trace Viewer Salvage trace upload as a smaller CLI-first feature: deterministic Claude Code JSONL export, fail-closed redaction, lazy Hugging Face dependency, and no gateway slash-command wiring. * chore(trace): drop external porting references from docstrings Describe the trace-upload design in Hermes' own terms. * feat(sessions): fold trace upload into 'sessions export --format trace' Integrates the HF Agent Trace Viewer exporter (PR #36145) onto the unified export surface instead of a separate 'hermes trace' subcommand: - --format trace: Claude Code JSONL to stdout/file, or one .trace.jsonl per session for filtered bulk export; defaults to the most recent session when no --session-id/filters given. - --upload pushes to the user's private HF traces dataset (--public to opt out of private); reads HF_TOKEN with guided setup when missing. - traces are secret-redacted by default (force mode); --no-redact opts out after review; redaction failure blocks export (fail closed). - hermes_cli/trace.py + subcommands/trace.py removed; agent/trace_upload.py is the single engine. Docs EN + zh-Hans; 4 new CLI tests. --- agent/trace_upload.py | 398 ++++++++++++++++++ hermes_cli/main.py | 144 ++++++- tests/agent/test_trace_upload.py | 261 ++++++++++++ .../hermes_cli/test_sessions_export_md_cli.py | 118 ++++++ tools/lazy_deps.py | 2 + website/docs/user-guide/sessions.md | 17 + .../current/user-guide/sessions.md | 17 + 7 files changed, 955 insertions(+), 2 deletions(-) create mode 100644 agent/trace_upload.py create mode 100644 tests/agent/test_trace_upload.py diff --git a/agent/trace_upload.py b/agent/trace_upload.py new file mode 100644 index 000000000000..f65547440c7f --- /dev/null +++ b/agent/trace_upload.py @@ -0,0 +1,398 @@ +"""Upload a Hermes session transcript to Hugging Face as an agent trace. + +Hermes stores sessions in its own SQLite store (``hermes_state.SessionDB``), +so we reconstruct the conversation and emit it in the **Claude Code JSONL** +shape — one of the three formats the Hugging Face Agent Trace Viewer +auto-detects (Claude Code / Codex / Pi). No dataset-side preprocessing is +needed; the Hub tags the dataset ``agent-traces`` and opens it in the viewer. + +Docs: https://huggingface.co/docs/hub/agent-traces + +Design notes +------------ +* **Zero LLM turn.** This is a deterministic export — it never spends a + model call. The ``hermes trace upload`` subcommand calls + :func:`upload_session_trace` directly. +* **Private by default.** Traces can contain prompts, tool output, local + paths, and secrets. The dataset is created private and every text body + is passed through Hermes' secret redactor (``force=True``) unless the + caller explicitly opts out with ``redact=False``. +* **Never raises.** Returns a user-facing status string so command + handlers can echo it straight back to the user. Programmatic callers + that need the URL can use :func:`build_trace_jsonl` + :func:`_do_upload` + directly. +""" + +from __future__ import annotations + +import json +import logging +import os +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +DEFAULT_DATASET_NAME = "hermes-traces" +_HERMES_VERSION = "hermes-agent" +_REDACTION_BLOCKED_MESSAGE = ( + "Trace upload blocked: secret redaction failed, so the transcript may " + "still contain credentials or other sensitive data. Fix the redactor or " + "rerun with --no-redact only after manually reviewing the transcript." +) + + +class TraceRedactionError(RuntimeError): + """Raised when a trace cannot be safely redacted before upload.""" + + +# --------------------------------------------------------------------------- +# Conversion: Hermes OpenAI-format messages -> Claude Code JSONL +# --------------------------------------------------------------------------- + +def _now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" + + +def _redact(text: Any, enabled: bool) -> Any: + """Redact secrets from a string body when redaction is enabled. + + Non-strings pass through untouched. Uses Hermes' shared redactor with + ``force=True`` so an upload always scrubs known secret shapes even if + the user disabled log redaction globally. + """ + if not enabled or not isinstance(text, str) or not text: + return text + try: + from agent.redact import redact_sensitive_text + return redact_sensitive_text(text, force=True) + except Exception as exc: + logger.warning("Trace upload redaction failed; refusing upload", exc_info=True) + raise TraceRedactionError(_REDACTION_BLOCKED_MESSAGE) from exc + + +def _content_to_blocks(content: Any, redact: bool) -> List[Dict[str, Any]]: + """Normalize a message ``content`` field into Anthropic content blocks.""" + if content is None: + return [] + if isinstance(content, str): + return [{"type": "text", "text": _redact(content, redact)}] + if isinstance(content, list): + blocks: List[Dict[str, Any]] = [] + for part in content: + if isinstance(part, dict): + ptype = part.get("type") + if ptype == "text": + blocks.append({"type": "text", "text": _redact(part.get("text", ""), redact)}) + elif ptype in ("image_url", "image"): + # Keep a placeholder; the viewer renders text turns and we + # don't want to inline base64 blobs into a trace. + blocks.append({"type": "text", "text": "[image omitted]"}) + else: + blocks.append({"type": "text", "text": _redact(json.dumps(part), redact)}) + else: + blocks.append({"type": "text", "text": _redact(str(part), redact)}) + return blocks + return [{"type": "text", "text": _redact(json.dumps(content), redact)}] + + +def _tool_calls_to_blocks(tool_calls: Any, redact: bool) -> List[Dict[str, Any]]: + """Convert OpenAI tool_calls into Anthropic ``tool_use`` content blocks.""" + blocks: List[Dict[str, Any]] = [] + if not isinstance(tool_calls, list): + return blocks + for tc in tool_calls: + if not isinstance(tc, dict): + continue + fn = tc.get("function") or {} + name = fn.get("name") or tc.get("name") or "tool" + raw_args = fn.get("arguments") + if isinstance(raw_args, str): + try: + parsed = json.loads(raw_args) if raw_args.strip() else {} + except (json.JSONDecodeError, ValueError): + parsed = {"_raw": raw_args} + elif isinstance(raw_args, dict): + parsed = raw_args + else: + parsed = {} + if redact: + try: + parsed = json.loads(_redact(json.dumps(parsed), redact)) + except (json.JSONDecodeError, ValueError): + logger.warning("Trace upload redacted tool arguments are not valid JSON; refusing upload") + raise TraceRedactionError(_REDACTION_BLOCKED_MESSAGE) + blocks.append({ + "type": "tool_use", + "id": tc.get("id") or f"toolu_{uuid.uuid4().hex[:16]}", + "name": name, + "input": parsed, + }) + return blocks + + +def build_trace_jsonl( + messages: List[Dict[str, Any]], + *, + session_id: str, + model: str = "", + cwd: str = "", + redact: bool = True, +) -> str: + """Render Hermes conversation messages as Claude Code JSONL text. + + Each non-system message becomes one JSONL line in the Claude Code + transcript shape the HF Agent Trace Viewer auto-detects: + + * ``user`` / ``tool`` -> ``{"type": "user", "message": {...}}`` + * ``assistant`` -> ``{"type": "assistant", "message": {...}}`` + with ``content`` blocks (text + ``tool_use``). + + Tool results are emitted as user turns carrying a ``tool_result`` + block keyed by ``tool_call_id`` — the same way Claude Code records + them. Turns are linked via ``uuid`` / ``parentUuid``. + """ + lines: List[str] = [] + parent: Optional[str] = None + base_ts = _now_iso() + git_branch = "" + try: + import subprocess + if cwd: + r = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, text=True, timeout=3, cwd=cwd, + ) + if r.returncode == 0: + git_branch = r.stdout.strip() + except Exception: + git_branch = "" + + def _common(turn_uuid: str) -> Dict[str, Any]: + return { + "parentUuid": parent, + "isSidechain": False, + "userType": "external", + "cwd": cwd or os.getcwd(), + "sessionId": session_id, + "version": _HERMES_VERSION, + "gitBranch": git_branch, + "uuid": turn_uuid, + "timestamp": base_ts, + } + + for msg in messages: + role = msg.get("role") + if role == "system": + continue + turn_uuid = str(uuid.uuid4()) + + if role == "assistant": + blocks = _content_to_blocks(msg.get("content"), redact) + blocks.extend(_tool_calls_to_blocks(msg.get("tool_calls"), redact)) + if not blocks: + blocks = [{"type": "text", "text": ""}] + entry = _common(turn_uuid) + entry["type"] = "assistant" + entry["message"] = { + "role": "assistant", + "model": model or "unknown", + "content": blocks, + } + lines.append(json.dumps(entry, ensure_ascii=False)) + parent = turn_uuid + continue + + if role == "tool": + tool_use_id = msg.get("tool_call_id") or msg.get("tool_name") or "tool" + result_content = _redact( + msg.get("content") if isinstance(msg.get("content"), str) + else json.dumps(msg.get("content")), + redact, + ) + entry = _common(turn_uuid) + entry["type"] = "user" + entry["message"] = { + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": result_content, + }], + } + lines.append(json.dumps(entry, ensure_ascii=False)) + parent = turn_uuid + continue + + # Default: user (and any unknown role) -> user turn. + content = msg.get("content") + if isinstance(content, str): + message_content: Any = _redact(content, redact) + else: + message_content = _content_to_blocks(content, redact) + entry = _common(turn_uuid) + entry["type"] = "user" + entry["message"] = {"role": "user", "content": message_content} + lines.append(json.dumps(entry, ensure_ascii=False)) + parent = turn_uuid + + return "\n".join(lines) + ("\n" if lines else "") + + +# --------------------------------------------------------------------------- +# Upload +# --------------------------------------------------------------------------- + +def _resolve_hf_token() -> Optional[str]: + """Return the user's Hugging Face token from the usual env vars.""" + for var in ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HUGGINGFACE_TOKEN"): + val = os.getenv(var) + if val and val.strip(): + return val.strip() + return None + + +_NO_TOKEN_MESSAGE = ( + "Can't upload — no Hugging Face token is available. To set it up:\n" + "\n" + "1. Create a token with WRITE access at https://huggingface.co/settings/tokens\n" + " (New token -> type \"Write\" -> copy it).\n" + "2. Add it to your environment as HF_TOKEN (e.g. in ~/.hermes/.env):\n" + " HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx\n" + "3. Run /upload-trace again (or `hermes trace upload`)." +) + + +def _do_upload( + jsonl: str, + *, + token: str, + session_id: str, + dataset_name: str = DEFAULT_DATASET_NAME, + private: bool = True, +) -> str: + """Create (idempotently) the private dataset and push the trace file. + + Returns a user-facing status string. Never raises. + """ + try: + from tools import lazy_deps + lazy_deps.ensure("tool.trace_upload", prompt=False) + except Exception: + # lazy-install unavailable/declined — fall through to the import, + # which surfaces the install hint below if the package is missing. + pass + try: + from huggingface_hub import HfApi + except ImportError: + return ("Hugging Face upload needs the `huggingface_hub` package " + "(`pip install huggingface_hub`).") + + api = HfApi(token=token) + try: + who = api.whoami() + user = who.get("name") if isinstance(who, dict) else None + except Exception as e: + logger.warning("HF whoami failed: %s", e) + return ("Your Hugging Face token was rejected (whoami failed). " + "Make sure it has WRITE access and isn't expired.") + if not user: + return "Could not resolve your Hugging Face username from the token." + + repo_id = f"{user}/{dataset_name}" + try: + api.create_repo( + repo_id=repo_id, repo_type="dataset", private=private, exist_ok=True, + ) + except Exception as e: + logger.warning("HF create_repo failed for %s: %s", repo_id, e) + return f"Could not create/access dataset {repo_id}: {e}" + + path_in_repo = f"sessions/{session_id}.jsonl" + try: + api.upload_file( + path_or_fileobj=jsonl.encode("utf-8"), + path_in_repo=path_in_repo, + repo_id=repo_id, + repo_type="dataset", + commit_message=f"add session trace {session_id}", + ) + except Exception as e: + logger.warning("HF upload_file failed for %s: %s", repo_id, e) + return f"Upload to Hugging Face failed: {e}" + + return (f"Uploaded -> https://huggingface.co/datasets/{repo_id}/blob/main/{path_in_repo}\n" + f"View in the trace viewer: https://huggingface.co/datasets/{repo_id}") + + +def load_session_messages( + session_id: str, db_path=None +) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: + """Load a session's conversation + metadata from the SQLite store. + + Returns ``(messages, meta)``. ``meta`` is ``{}`` when the session row is + missing (messages may still be present for a live, untitled session). + """ + from hermes_state import SessionDB + db = SessionDB(db_path=db_path) if db_path else SessionDB() + resolved = db.resolve_session_id(session_id) or session_id + meta = db.get_session(resolved) or {} + messages = db.get_messages_as_conversation(resolved) + return messages, meta + + +def upload_session_trace( + session_id: str, + *, + model: str = "", + cwd: str = "", + redact: bool = True, + private: bool = True, + dataset_name: str = DEFAULT_DATASET_NAME, + db_path=None, + token: Optional[str] = None, +) -> str: + """Top-level entry point used by the CLI/gateway/subcommand. + + Loads the session, converts it to Claude Code JSONL, and uploads it to + the user's private ``{user}/hermes-traces`` dataset. Returns a + user-facing status string and never raises. + """ + if not session_id: + return "No active session to upload." + + token = token or _resolve_hf_token() + if not token: + return _NO_TOKEN_MESSAGE + + try: + messages, meta = load_session_messages(session_id, db_path=db_path) + except Exception as e: + logger.warning("Failed to load session %s for trace upload: %s", session_id, e) + return f"Could not load session {session_id}: {e}" + + if not messages: + return "No transcript to upload for this session yet." + + resolved_model = model or meta.get("model") or "" + try: + jsonl = build_trace_jsonl( + messages, + session_id=session_id, + model=resolved_model, + cwd=cwd, + redact=redact, + ) + except TraceRedactionError: + return _REDACTION_BLOCKED_MESSAGE + if not jsonl.strip(): + return "No transcript content to upload for this session." + + return _do_upload( + jsonl, + token=token, + session_id=session_id, + dataset_name=dataset_name, + private=private, + ) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3593d78fdbc0..7326badcda89 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13543,9 +13543,33 @@ def main(): ) sessions_export.add_argument( "--format", - choices=["jsonl", "md", "qmd", "html"], + choices=["jsonl", "md", "qmd", "html", "trace"], default="jsonl", - help="Export format (default: jsonl)", + help=( + "Export format (default: jsonl). 'trace' emits Claude Code JSONL " + "for the Hugging Face Agent Trace Viewer" + ), + ) + sessions_export.add_argument( + "--upload", + action="store_true", + help=( + "trace only: upload to your Hugging Face traces dataset instead " + "of writing a local file (needs HF_TOKEN)" + ), + ) + sessions_export.add_argument( + "--public", + action="store_true", + help="trace --upload only: create/update a public dataset instead of private", + ) + sessions_export.add_argument( + "--no-redact", + action="store_true", + help=( + "trace only: skip the forced secret redaction; " + "only use after manual review" + ), ) sessions_export.add_argument( "--only", @@ -13889,6 +13913,122 @@ def main(): db.close() return + # Claude Code JSONL trace export — local file or HF upload. + # Redaction is ON by default for traces (they leave the machine + # when --upload is used); --no-redact opts out after review. + if args.format == "trace": + if getattr(args, "only", None): + print("--only user-prompts supports --format jsonl or md.") + db.close() + return + session_id = args.session_id + if not session_id and not filters: + # Match the shell's common intent: "the last thing I did". + rows = db.list_sessions_rich(limit=1, order_by_last_active=True) + session_id = rows[0].get("id") if rows else None + if not session_id: + print("No session found to export. Pass --session-id.") + db.close() + return + if session_id and not db.resolve_session_id(session_id): + print(f"Session '{session_id}' not found.") + db.close() + return + + from agent.trace_upload import ( + TraceRedactionError, + build_trace_jsonl, + upload_session_trace, + ) + + redact_trace = not getattr(args, "no_redact", False) + + if getattr(args, "upload", False): + if not session_id: + print("--upload exports one session: pass --session-id (or drop filters to use the most recent).") + db.close() + return + resolved = db.resolve_session_id(session_id) + db.close() + status = upload_session_trace( + resolved, + cwd="", + redact=redact_trace, + private=not getattr(args, "public", False), + ) + print(status) + return + + # Local trace file(s) + def _trace_ids(): + if session_id: + return [db.resolve_session_id(session_id)] + candidates = db.list_prune_candidates(**filters) + if args.dry_run: + print( + f"Would export {len(candidates)} session(s) " + f"({describe_filters(filters)})." + ) + for row in candidates[:100]: + print(f" {row.get('id')} {row.get('source', '')}") + if len(candidates) > 100: + print(f" ... {len(candidates) - 100} more") + return None + return [row["id"] for row in candidates] + + ids = _trace_ids() + if ids is None: + db.close() + return + + def _render_trace(sid): + meta = db.get_session(sid) or {} + messages = db.get_messages_as_conversation(sid) + if not messages: + return None + return build_trace_jsonl( + messages, + session_id=sid, + model=meta.get("model") or "", + cwd="", + redact=redact_trace, + ) + + try: + if len(ids) == 1: + jsonl = _render_trace(ids[0]) + if not jsonl: + print(f"No transcript to export for session '{ids[0]}'.") + db.close() + return + if not args.output or args.output == "-": + sys.stdout.write(jsonl) + else: + with open(args.output, "w", encoding="utf-8") as f: + f.write(jsonl) + print(f"Exported 1 session trace to {args.output}") + else: + out_dir = ( + Path(args.output).expanduser() + if args.output and args.output != "-" + else get_hermes_home() / "session-exports" + ) + out_dir.mkdir(parents=True, exist_ok=True) + exported = 0 + for sid in ids: + jsonl = _render_trace(sid) + if not jsonl: + continue + (out_dir / f"{sid}.trace.jsonl").write_text( + jsonl, encoding="utf-8" + ) + exported += 1 + print(f"Exported {exported} session trace(s) to {out_dir}") + except TraceRedactionError: + print("Redaction failed; refusing to export unredacted trace content.") + db.close() + return + if args.format == "jsonl": if not args.output: print("JSONL export requires an output path (use - for stdout).") diff --git a/tests/agent/test_trace_upload.py b/tests/agent/test_trace_upload.py new file mode 100644 index 000000000000..db8a8925074d --- /dev/null +++ b/tests/agent/test_trace_upload.py @@ -0,0 +1,261 @@ +"""Tests for agent.trace_upload — Hugging Face session-trace upload. + +Covers the Claude Code JSONL converter, HF token resolution, the no-token +message path, and the upload path with a mocked ``HfApi`` (verifying repo +id, file path, and content without touching the network). +""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from agent import trace_upload +from agent.trace_upload import ( + build_trace_jsonl, + upload_session_trace, + _resolve_hf_token, + _do_upload, +) + + +# --------------------------------------------------------------------------- +# Converter +# --------------------------------------------------------------------------- + +def _sample_messages(): + return [ + {"role": "system", "content": "you are hermes"}, + {"role": "user", "content": "list files"}, + {"role": "assistant", "content": "Listing.", "tool_calls": [ + {"id": "call_1", "function": {"name": "terminal", "arguments": '{"command": "ls"}'}}, + ]}, + {"role": "tool", "tool_call_id": "call_1", "tool_name": "terminal", "content": "a.txt\nb.txt"}, + {"role": "assistant", "content": "Two files."}, + ] + + +def test_converter_skips_system_and_counts_lines(): + jsonl = build_trace_jsonl(_sample_messages(), session_id="s1", model="m") + lines = [json.loads(x) for x in jsonl.strip().split("\n")] + assert len(lines) == 4 # system dropped + assert all(o["sessionId"] == "s1" for o in lines) + + +def test_converter_links_turns_as_linked_list(): + jsonl = build_trace_jsonl(_sample_messages(), session_id="s1") + lines = [json.loads(x) for x in jsonl.strip().split("\n")] + prev = None + for o in lines: + assert o["parentUuid"] == prev + prev = o["uuid"] + + +def test_converter_emits_tool_use_and_tool_result(): + jsonl = build_trace_jsonl(_sample_messages(), session_id="s1", model="m") + lines = [json.loads(x) for x in jsonl.strip().split("\n")] + # line 0 user, line 1 assistant (text + tool_use), line 2 tool_result, line 3 assistant + assert lines[0]["type"] == "user" + assert lines[1]["type"] == "assistant" + blocks = lines[1]["message"]["content"] + assert any(b.get("type") == "text" for b in blocks) + tool_use = [b for b in blocks if b.get("type") == "tool_use"] + assert tool_use and tool_use[0]["name"] == "terminal" + assert tool_use[0]["input"] == {"command": "ls"} + # tool result rides on a user turn + assert lines[2]["type"] == "user" + tr = lines[2]["message"]["content"][0] + assert tr["type"] == "tool_result" + assert tr["tool_use_id"] == "call_1" + assert "a.txt" in tr["content"] + + +def test_converter_redacts_secrets_by_default(): + msgs = [{"role": "user", "content": "key OPENAI_API_KEY=sk-abc123def456ghi789jklmno end"}] + jsonl = build_trace_jsonl(msgs, session_id="s1", redact=True) + assert "sk-abc123def456ghi789jklmno" not in jsonl + + +def test_converter_refuses_unredacted_passthrough_when_redactor_fails(monkeypatch): + def boom(_text, *, force=False): + raise RuntimeError("redactor unavailable") + + monkeypatch.setattr("agent.redact.redact_sensitive_text", boom) + msgs = [{"role": "user", "content": "OPENAI_API_KEY=sk-abc123def456ghi789jklmno"}] + + with pytest.raises(trace_upload.TraceRedactionError): + build_trace_jsonl(msgs, session_id="s1", redact=True) + + +def test_upload_blocks_when_redactor_fails(monkeypatch): + monkeypatch.setenv("HF_TOKEN", "hf_test") + + def boom(_text, *, force=False): + raise RuntimeError("redactor unavailable") + + monkeypatch.setattr("agent.redact.redact_sensitive_text", boom) + with patch.object(trace_upload, "load_session_messages", return_value=(_sample_messages(), {})), \ + patch.object(trace_upload, "_do_upload") as upload_mock: + msg = upload_session_trace("s1") + + assert "Trace upload blocked" in msg + upload_mock.assert_not_called() + + +def test_converter_keeps_secrets_when_redact_disabled(): + secret = "sk-abc123def456ghi789jklmno" + msgs = [{"role": "user", "content": f"key OPENAI_API_KEY={secret} end"}] + jsonl = build_trace_jsonl(msgs, session_id="s1", redact=False) + assert secret in jsonl + + +def test_converter_image_placeholder(): + msgs = [{"role": "user", "content": [ + {"type": "text", "text": "look"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, + ]}] + jsonl = build_trace_jsonl(msgs, session_id="s1") + line = json.loads(jsonl.strip()) + assert any("image omitted" in b.get("text", "") for b in line["message"]["content"]) + assert "AAAA" not in jsonl + + +def test_converter_empty_messages_returns_empty(): + assert build_trace_jsonl([], session_id="s1") == "" + + +def test_converter_handles_dict_tool_arguments(): + msgs = [{"role": "assistant", "content": "", "tool_calls": [ + {"id": "c", "function": {"name": "f", "arguments": {"already": "dict"}}}, + ]}] + jsonl = build_trace_jsonl(msgs, session_id="s1") + line = json.loads(jsonl.strip()) + tu = [b for b in line["message"]["content"] if b.get("type") == "tool_use"][0] + assert tu["input"] == {"already": "dict"} + + +# --------------------------------------------------------------------------- +# Token resolution +# --------------------------------------------------------------------------- + +def test_resolve_token_prefers_hf_token(monkeypatch): + monkeypatch.setenv("HF_TOKEN", "hf_primary") + monkeypatch.setenv("HUGGINGFACE_TOKEN", "hf_secondary") + assert _resolve_hf_token() == "hf_primary" + + +def test_resolve_token_falls_back(monkeypatch): + monkeypatch.delenv("HF_TOKEN", raising=False) + monkeypatch.delenv("HUGGINGFACE_HUB_TOKEN", raising=False) + monkeypatch.delenv("HUGGING_FACE_HUB_TOKEN", raising=False) + monkeypatch.setenv("HUGGINGFACE_TOKEN", "hf_fallback") + assert _resolve_hf_token() == "hf_fallback" + + +def test_resolve_token_none(monkeypatch): + for v in ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HUGGINGFACE_TOKEN"): + monkeypatch.delenv(v, raising=False) + assert _resolve_hf_token() is None + + +# --------------------------------------------------------------------------- +# Top-level upload entry point +# --------------------------------------------------------------------------- + +def test_upload_no_session_id(): + assert "No active session" in upload_session_trace("") + + +def test_upload_no_token(monkeypatch): + for v in ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HUGGINGFACE_TOKEN"): + monkeypatch.delenv(v, raising=False) + msg = upload_session_trace("some_session") + assert "no Hugging Face token" in msg + + +def test_upload_empty_transcript(monkeypatch): + monkeypatch.setenv("HF_TOKEN", "hf_test") + with patch.object(trace_upload, "load_session_messages", return_value=([], {})): + msg = upload_session_trace("s1") + assert "No transcript" in msg + + +def test_upload_happy_path_mocked(monkeypatch): + """Full upload path with a mocked HfApi — verifies repo id / path / content.""" + pytest.importorskip("huggingface_hub") # optional dep; runtime degrades gracefully + monkeypatch.setenv("HF_TOKEN", "hf_test") + messages = _sample_messages() + + fake_api = MagicMock() + fake_api.whoami.return_value = {"name": "alice"} + + with patch.object(trace_upload, "load_session_messages", + return_value=(messages, {"model": "claude-x"})), \ + patch("huggingface_hub.HfApi", return_value=fake_api): + msg = upload_session_trace("20260531_abc", cwd="/tmp") + + # Returned a viewer URL + assert "huggingface.co/datasets/alice/hermes-traces" in msg + + # Created private dataset repo + fake_api.create_repo.assert_called_once() + _, kwargs = fake_api.create_repo.call_args + assert kwargs["repo_id"] == "alice/hermes-traces" + assert kwargs["repo_type"] == "dataset" + assert kwargs["private"] is True + + # Uploaded the JSONL to sessions/.jsonl + fake_api.upload_file.assert_called_once() + _, ukwargs = fake_api.upload_file.call_args + assert ukwargs["path_in_repo"] == "sessions/20260531_abc.jsonl" + assert ukwargs["repo_id"] == "alice/hermes-traces" + body = ukwargs["path_or_fileobj"] + if isinstance(body, bytes): + body = body.decode("utf-8") + # Content is valid Claude Code JSONL + first = json.loads(body.strip().split("\n")[0]) + assert first["type"] in ("user", "assistant") + assert first["sessionId"] == "20260531_abc" + + +def test_upload_public_flag(monkeypatch): + pytest.importorskip("huggingface_hub") # optional dep + monkeypatch.setenv("HF_TOKEN", "hf_test") + fake_api = MagicMock() + fake_api.whoami.return_value = {"name": "bob"} + with patch.object(trace_upload, "load_session_messages", + return_value=(_sample_messages(), {})), \ + patch("huggingface_hub.HfApi", return_value=fake_api): + upload_session_trace("s1", private=False) + _, kwargs = fake_api.create_repo.call_args + assert kwargs["private"] is False + + +def test_upload_whoami_failure(monkeypatch): + pytest.importorskip("huggingface_hub") # optional dep + monkeypatch.setenv("HF_TOKEN", "hf_bad") + fake_api = MagicMock() + fake_api.whoami.side_effect = Exception("401 unauthorized") + with patch.object(trace_upload, "load_session_messages", + return_value=(_sample_messages(), {})), \ + patch("huggingface_hub.HfApi", return_value=fake_api): + msg = upload_session_trace("s1") + assert "token was rejected" in msg + + +def test_do_upload_missing_huggingface_hub(monkeypatch): + """If huggingface_hub import fails, return a clear install hint.""" + # Disable lazy-install so the import path deterministically fails here + # instead of attempting a real pip install in CI. + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + import builtins + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "huggingface_hub": + raise ImportError("no module") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + msg = _do_upload("{}\n", token="t", session_id="s1") + assert "huggingface_hub" in msg diff --git a/tests/hermes_cli/test_sessions_export_md_cli.py b/tests/hermes_cli/test_sessions_export_md_cli.py index eed3cb244332..76c634b7105f 100644 --- a/tests/hermes_cli/test_sessions_export_md_cli.py +++ b/tests/hermes_cli/test_sessions_export_md_cli.py @@ -504,3 +504,121 @@ def test_sessions_export_redact_scrubs_secrets(monkeypatch, tmp_path): text = next(tmp_path.glob("*.md")).read_text(encoding="utf-8") assert secret not in text assert "api key:" in text + + +def _trace_fake_db(captured): + class FakeDB: + def resolve_session_id(self, session_id): + return "s1" + + def get_session(self, session_id): + return {"id": session_id, "model": "test-model"} + + def get_messages_as_conversation(self, session_id): + captured["conv"] = session_id + return [ + {"role": "user", "content": "hello trace"}, + {"role": "assistant", "content": "hi"}, + ] + + def close(self): + captured["closed"] = True + + return FakeDB() + + +def test_sessions_export_trace_writes_claude_jsonl(monkeypatch, tmp_path, capsys): + import json + + import hermes_cli.main as main_mod + import hermes_state + + captured = {} + out = tmp_path / "trace.jsonl" + monkeypatch.setattr(hermes_state, "SessionDB", lambda: _trace_fake_db(captured)) + monkeypatch.setattr( + sys, + "argv", + ["hermes", "sessions", "export", "--format", "trace", "--session-id", "s1", str(out)], + ) + + main_mod.main() + + assert "Exported 1 session trace" in capsys.readouterr().out + lines = [json.loads(line) for line in out.read_text(encoding="utf-8").splitlines()] + assert {rec["type"] for rec in lines} == {"user", "assistant"} + assert all("uuid" in rec for rec in lines) + assert captured["conv"] == "s1" + assert captured["closed"] is True + + +def test_sessions_export_trace_stdout(monkeypatch, capsys): + import json + + import hermes_cli.main as main_mod + import hermes_state + + captured = {} + monkeypatch.setattr(hermes_state, "SessionDB", lambda: _trace_fake_db(captured)) + monkeypatch.setattr( + sys, + "argv", + ["hermes", "sessions", "export", "--format", "trace", "--session-id", "s1", "-"], + ) + + main_mod.main() + + lines = [json.loads(line) for line in capsys.readouterr().out.splitlines()] + assert len(lines) == 2 + assert lines[0]["type"] == "user" + + +def test_sessions_export_trace_upload_routes_to_uploader(monkeypatch, capsys): + import hermes_cli.main as main_mod + import hermes_state + from agent import trace_upload as trace_mod + + captured = {} + monkeypatch.setattr(hermes_state, "SessionDB", lambda: _trace_fake_db(captured)) + + def fake_upload(session_id, **kwargs): + captured["uploaded"] = session_id + captured["kwargs"] = kwargs + return "Uploaded -> https://example.test/dataset" + + monkeypatch.setattr(trace_mod, "upload_session_trace", fake_upload) + monkeypatch.setattr( + sys, + "argv", + [ + "hermes", "sessions", "export", "--format", "trace", + "--session-id", "s1", "--upload", "--public", + ], + ) + + main_mod.main() + + assert captured["uploaded"] == "s1" + assert captured["kwargs"]["private"] is False + assert captured["kwargs"]["redact"] is True + assert "Uploaded ->" in capsys.readouterr().out + + +def test_sessions_export_trace_only_flag_rejected(monkeypatch, capsys): + import hermes_cli.main as main_mod + import hermes_state + + captured = {} + monkeypatch.setattr(hermes_state, "SessionDB", lambda: _trace_fake_db(captured)) + monkeypatch.setattr( + sys, + "argv", + [ + "hermes", "sessions", "export", "--format", "trace", + "--session-id", "s1", "--only", "user-prompts", "-", + ], + ) + + main_mod.main() + + assert "--only user-prompts supports --format jsonl or md." in capsys.readouterr().out diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 9f8081ca2e21..94cd069b8d33 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -238,6 +238,8 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { "mcp==1.26.0", "starlette==1.0.1", # CVE-2026-48710 — keep in sync with pyproject [computer-use] ), + # HF Agent Trace Viewer upload (hermes trace upload / /upload-trace). + "tool.trace_upload": ("huggingface-hub==1.2.3",), } diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index e2ae75ea7d4a..96a0c6484925 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -338,6 +338,23 @@ hermes sessions export - --session-id 20250305_091523_a1b2c3d4 --only user-promp Works with `--format jsonl` (default) or `md`, honors the same filters for bulk export, and combines with `--redact`. +### Export Traces to the HF Agent Trace Viewer + +`--format trace` emits Claude Code JSONL — the transcript shape the Hugging Face Hub auto-detects for its [Agent Trace Viewer](https://huggingface.co/docs/hub/agent-traces). Write it locally, or add `--upload` to push it to your own private `hermes-traces` dataset (reads `HF_TOKEN`): + +```bash +# Trace of the most recent session, to stdout +hermes sessions export --format trace + +# One session to a local trace file +hermes sessions export --format trace --session-id 20250305_091523_a1b2c3d4 trace.jsonl + +# Upload straight to your private HF traces dataset +hermes sessions export --format trace --session-id 20250305_091523_a1b2c3d4 --upload +``` + +Trace exports are secret-redacted by default (they're meant to leave the machine); `--no-redact` opts out after manual review. `--upload` is private unless `--public`. Bulk trace export with filters writes one `.trace.jsonl` per session. + ### Export Sessions to Markdown/QMD Pass `--format md` or `--format qmd` when you want a readable, file-based archive before hiding or deleting old sessions. Markdown/QMD exports write one file per session into a directory (default: `~/.hermes/session-exports`). diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md index 78c8d58be7e1..620317d3fd18 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md @@ -315,6 +315,23 @@ hermes sessions export - --session-id 20250305_091523_a1b2c3d4 --only user-promp 支持 `--format jsonl`(默认)或 `md`,批量导出时同样支持全部过滤器,也可与 `--redact` 组合。 +### 导出 Trace 到 HF Agent Trace Viewer + +`--format trace` 生成 Claude Code JSONL — Hugging Face Hub 的 [Agent Trace Viewer](https://huggingface.co/docs/hub/agent-traces) 可自动识别的转录格式。可以写入本地文件,或加 `--upload` 推送到你自己的私有 `hermes-traces` 数据集(读取 `HF_TOKEN`): + +```bash +# 最近一个 session 的 trace,输出到 stdout +hermes sessions export --format trace + +# 将一个 session 导出为本地 trace 文件 +hermes sessions export --format trace --session-id 20250305_091523_a1b2c3d4 trace.jsonl + +# 直接上传到你的私有 HF traces 数据集 +hermes sessions export --format trace --session-id 20250305_091523_a1b2c3d4 --upload +``` + +Trace 导出默认强制脱敏(它们本来就是要离开本机的);`--no-redact` 需人工审查后才建议使用。`--upload` 默认私有,除非加 `--public`。带过滤器的批量 trace 导出会为每个 session 写一个 `.trace.jsonl`。 + ### 导出 Session 为 Markdown/QMD 当你想在隐藏或删除旧 session 之前保留一份可读的文件归档时,传入 `--format md` 或 `--format qmd`。Markdown/QMD 导出会为每个 session 写入一个文件到目录中(默认:`~/.hermes/session-exports`)。 From 37a4cf9000ce8e0da6017cbe8bb4a2e8617a7737 Mon Sep 17 00:00:00 2001 From: Ronald Reis Date: Thu, 2 Jul 2026 10:55:07 +0100 Subject: [PATCH 146/610] fix: limit desktop model pickers to explicit providers --- apps/desktop/src/app/chat/index.tsx | 5 +- .../src/app/settings/model-settings.test.tsx | 48 ++++--------------- .../components/model-visibility-dialog.tsx | 5 +- .../src/components/onboarding/flow.tsx | 2 +- .../src/components/onboarding/index.tsx | 2 +- apps/desktop/src/hermes.test.ts | 22 ++++++++- apps/desktop/src/hermes.ts | 22 ++++++++- apps/desktop/src/lib/model-options.ts | 11 ++++- apps/desktop/src/store/onboarding.ts | 2 +- hermes_cli/auth.py | 27 +++++++++++ hermes_cli/inventory.py | 39 +++++++++++++++ hermes_cli/web_server.py | 22 +++++---- tests/hermes_cli/test_inventory.py | 38 +++++++++++++++ .../test_web_server_profile_unification.py | 30 ++++++++++++ tests/test_tui_gateway_server.py | 46 ++++++++++++++++++ tui_gateway/server.py | 11 +++-- ui-tui/src/components/modelPicker.tsx | 7 ++- web/src/components/ModelPickerDialog.tsx | 4 ++ web/src/lib/api.test.ts | 4 +- web/src/lib/api.ts | 5 ++ 20 files changed, 287 insertions(+), 65 deletions(-) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index a5216210a799..74eb8df86613 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -358,7 +358,10 @@ export function ChatView({ throw new Error('Hermes gateway unavailable') } - return gateway.request('model.options', { session_id: activeSessionId }) + return gateway.request('model.options', { + session_id: activeSessionId, + explicit_only: true + }) }, enabled: gatewayOpen }) diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index 405fb49cd727..135e9e268f2f 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -13,8 +13,10 @@ beforeAll(() => { const getGlobalModelInfo = vi.fn() const getGlobalModelOptions = vi.fn() const getAuxiliaryModels = vi.fn() +const getMoaModels = vi.fn() const setModelAssignment = vi.fn() const getRecommendedDefaultModel = vi.fn() +const saveMoaModels = vi.fn() const setEnvVar = vi.fn() const getHermesConfigRecord = vi.fn() const saveHermesConfig = vi.fn() @@ -24,8 +26,10 @@ vi.mock('@/hermes', () => ({ getGlobalModelInfo: () => getGlobalModelInfo(), getGlobalModelOptions: () => getGlobalModelOptions(), getAuxiliaryModels: () => getAuxiliaryModels(), + getMoaModels: () => getMoaModels(), setModelAssignment: (body: unknown) => setModelAssignment(body), getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug), + saveMoaModels: (body: unknown) => saveMoaModels(body), setEnvVar: (key: string, value: string) => setEnvVar(key, value), getHermesConfigRecord: () => getHermesConfigRecord(), saveHermesConfig: (config: unknown) => saveHermesConfig(config) @@ -45,15 +49,6 @@ beforeEach(() => { models: ['hermes-4', 'hermes-4-mini'], authenticated: true, capabilities: { 'hermes-4': { reasoning: true, fast: true } } - }, - // An unconfigured api_key provider — surfaced by the full-universe payload. - { - name: 'DeepSeek', - slug: 'deepseek', - models: [], - authenticated: false, - auth_type: 'api_key', - key_env: 'DEEPSEEK_API_KEY' } ] }) @@ -61,8 +56,9 @@ beforeEach(() => { main: { provider: 'nous', model: 'hermes-4' }, tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }] }) + getMoaModels.mockResolvedValue(null) setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] }) - getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null }) + getRecommendedDefaultModel.mockResolvedValue({ provider: 'nous', model: 'hermes-4', free_tier: null }) setEnvVar.mockResolvedValue({ ok: true }) getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } }) saveHermesConfig.mockResolvedValue({ ok: true }) @@ -80,43 +76,19 @@ async function renderModelSettings() { } describe('ModelSettings', () => { - it('loads the current main model and lists the full provider universe', async () => { + it('loads the current main model and lists configured providers only', async () => { await renderModelSettings() await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled()) await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled()) - // Open the provider Select — every provider from the full payload should be - // listed, including the unconfigured one with its "set up" hint. + // Open the provider Select — only configured providers should be listed. const triggers = await screen.findAllByRole('combobox') fireEvent.click(triggers[0]) - // "Nous" shows in both the trigger and the open list; the unconfigured - // provider + its setup hint are the unique signal of the full universe. + // "Nous" shows in both the trigger and the open list. expect((await screen.findAllByText('Nous')).length).toBeGreaterThan(0) - expect(await screen.findByText(/DeepSeek/)).toBeTruthy() - expect(await screen.findByText(/set up/)).toBeTruthy() - }) - - it('activates an unconfigured api_key provider inline by saving its key', async () => { - await renderModelSettings() - - await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled()) - - // Open the provider Select and pick the unconfigured provider. - const triggers = screen.getAllByRole('combobox') - fireEvent.click(triggers[0]) - const deepseekOption = await screen.findByText(/DeepSeek/) - fireEvent.click(deepseekOption) - - // The inline key input appears for an api_key provider that needs setup. - const keyInput = await screen.findByPlaceholderText(/Paste DEEPSEEK_API_KEY/) - fireEvent.change(keyInput, { target: { value: 'sk-test-123' } }) - - const activate = await screen.findByRole('button', { name: /Activate/ }) - fireEvent.click(activate) - - await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123')) + expect(screen.queryByText(/DeepSeek/)).toBeNull() }) it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => { diff --git a/apps/desktop/src/components/model-visibility-dialog.tsx b/apps/desktop/src/components/model-visibility-dialog.tsx index 8f1aad94767f..09c11b838bde 100644 --- a/apps/desktop/src/components/model-visibility-dialog.tsx +++ b/apps/desktop/src/components/model-visibility-dialog.tsx @@ -45,7 +45,10 @@ export function ModelVisibilityDialog({ queryKey: ['model-options', sessionId || 'global'], queryFn: (): Promise => { if (gw && sessionId) { - return gw.request('model.options', { session_id: sessionId }) + return gw.request('model.options', { + session_id: sessionId, + explicit_only: true + }) } return getGlobalModelOptions() diff --git a/apps/desktop/src/components/onboarding/flow.tsx b/apps/desktop/src/components/onboarding/flow.tsx index 6e9fb1ef51f8..780576670f47 100644 --- a/apps/desktop/src/components/onboarding/flow.tsx +++ b/apps/desktop/src/components/onboarding/flow.tsx @@ -237,7 +237,7 @@ function ConfirmingModelPanel({ // shows the same $/Mtok + Free/Pro info the picker and CLI do. const options = useQuery({ queryKey: ['onboarding-model-options', flow.providerSlug], - queryFn: () => getGlobalModelOptions() + queryFn: () => getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false }) }) const providerRow = options.data?.providers?.find( diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx index d6d08cb97118..0b81db3775b7 100644 --- a/apps/desktop/src/components/onboarding/index.tsx +++ b/apps/desktop/src/components/onboarding/index.tsx @@ -99,7 +99,7 @@ function useApiKeyCatalog(): ApiKeyOption[] { // Promise.resolve().then so a synchronous throw (e.g. no desktop bridge in // tests) is funneled into the same .catch instead of escaping. void Promise.resolve() - .then(() => getGlobalModelOptions()) + .then(() => getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false })) .then(res => { if (!cancelled) { setRows(res.providers ?? []) diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index e9ad04a55110..882b5588663d 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -100,7 +100,7 @@ describe('Hermes REST session helpers', () => { [getHermesConfig, '/api/config'], [getHermesConfigDefaults, '/api/config/defaults'], [getGlobalModelInfo, '/api/model/info'], - [() => getGlobalModelOptions(), '/api/model/options'], + [() => getGlobalModelOptions(), '/api/model/options?explicit_only=1'], [getCronJobs, '/api/cron/jobs'] ] @@ -134,4 +134,24 @@ describe('Hermes REST session helpers', () => { profile: 'xiaoxuxu' }) }) + + it('defaults model options to configured providers only', async () => { + await getGlobalModelOptions() + + expect(api).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/model/options?explicit_only=1' + }) + ) + }) + + it('can opt into unconfigured providers for onboarding flows', async () => { + await getGlobalModelOptions({ includeUnconfigured: true, refresh: true, explicitOnly: false }) + + expect(api).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/model/options?refresh=1&include_unconfigured=1' + }) + ) + }) }) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index cf03e0d83657..3bfd02e276fd 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -869,10 +869,28 @@ export function getUsageAnalytics(days = 30): Promise { }) } -export function getGlobalModelOptions(opts?: { refresh?: boolean }): Promise { +export function getGlobalModelOptions(opts?: { + refresh?: boolean + includeUnconfigured?: boolean + explicitOnly?: boolean +}): Promise { + const params = new URLSearchParams() + + if (opts?.refresh) { + params.set('refresh', '1') + } + + if (opts?.includeUnconfigured) { + params.set('include_unconfigured', '1') + } + + if (opts?.explicitOnly !== false) { + params.set('explicit_only', '1') + } + return window.hermesDesktop.api({ ...profileScoped(), - path: opts?.refresh ? '/api/model/options?refresh=1' : '/api/model/options', + path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } diff --git a/apps/desktop/src/lib/model-options.ts b/apps/desktop/src/lib/model-options.ts index a76555ec6677..11ae63bf202c 100644 --- a/apps/desktop/src/lib/model-options.ts +++ b/apps/desktop/src/lib/model-options.ts @@ -1,12 +1,17 @@ import { getGlobalModelOptions, type HermesGateway, type ModelOptionsResponse } from '@/hermes' interface ModelOptionsRequest { + /** When false, include ambient/unconfigured providers (onboarding/setup + * surfaces). Chat pickers default to true so only explicitly configured + * providers are listed (#56974). */ + explicitOnly?: boolean gateway?: HermesGateway refresh?: boolean sessionId?: null | string } export function requestModelOptions({ + explicitOnly = true, gateway, refresh = false, sessionId @@ -22,8 +27,12 @@ export function requestModelOptions({ params.refresh = true } + if (explicitOnly) { + params.explicit_only = true + } + return gateway.request('model.options', params) } - return getGlobalModelOptions(refresh ? { refresh: true } : undefined) + return getGlobalModelOptions({ explicitOnly, ...(refresh ? { refresh: true } : {}) }) } diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index c9c9606f3499..f257af1e5fe7 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -239,7 +239,7 @@ async function fetchProviderDefaultModel( let options try { - options = await getGlobalModelOptions() + options = await getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false }) } catch { return null } diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 8ac31020defe..179d8697e9d5 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1495,6 +1495,33 @@ def is_provider_explicitly_configured(provider_id: str) -> bool: if has_usable_secret(os.getenv(env_var, "")): return True + # 4. Check persisted credential-pool entries that came from EXPLICIT flows + # the user initiated inside Hermes (manual add / device-code / PKCE), plus + # env-backed pool entries. This intentionally excludes ambient borrowed + # sources like gh_cli / claude_code / qwen-cli. + try: + for entry in read_credential_pool(normalized): + if not isinstance(entry, dict): + continue + source = str(entry.get("source") or "").strip().lower() + if not source: + continue + if source.startswith("env:"): + # A stale env-seeded pool entry survives in auth.json after + # the user deletes the env var (#55790) — only count it when + # the referenced var still resolves to a usable secret NOW. + env_var = entry.get("source", "").split(":", 1)[1].strip() + if env_var and has_usable_secret(os.getenv(env_var, "")): + return True + continue + if ( + source in {"device_code", "loopback_pkce", "hermes_pkce", "manual"} + or source.startswith("manual:") + ): + return True + except Exception: + pass + return False diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index ad279ca4f722..08cfe322b878 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -111,6 +111,7 @@ def load_picker_context() -> ConfigContext: def build_models_payload( ctx: ConfigContext, *, + explicit_only: bool = False, include_unconfigured: bool = False, picker_hints: bool = False, canonical_order: bool = False, @@ -126,6 +127,10 @@ def build_models_payload( needs from a single substrate call. Flags: + - ``explicit_only``: keep only providers the user explicitly configured + (current provider, providers from config, or providers backed by + provider-specific env vars). This hides ambient / auto-seeded + credentials from desktop chat pickers. - ``include_unconfigured``: append ``CANONICAL_PROVIDERS`` rows that ``list_authenticated_providers`` didn't emit (TUI uses this to show the full provider universe in the picker). @@ -180,6 +185,9 @@ def build_models_payload( if moa_row is not None: rows = [moa_row] + [r for r in rows if str(r.get("slug", "")).lower() != "moa"] + if explicit_only: + rows = _filter_explicit_provider_rows(rows, ctx) + # --- Deduplicate: remove models from aggregators that overlap with # user-defined providers. When a local proxy (e.g. litellm-proxy) # serves a model whose name also appears in an aggregator's curated @@ -308,6 +316,37 @@ def _append_unconfigured_rows(rows: list[dict], ctx: ConfigContext) -> list[dict return extras +def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]: + """Keep only rows backed by explicit user configuration. + + ``list_authenticated_providers`` intentionally discovers ambient / auto- + seeded credentials (for example GitHub CLI -> Copilot). Desktop chat model + pickers want the narrower subset the user explicitly configured for Hermes. + """ + from hermes_cli.auth import is_provider_explicitly_configured + + current_slug = str(ctx.current_provider or "").strip().lower() + kept: list[dict] = [] + for row in rows: + slug = str(row.get("slug", "")).strip().lower() + if not slug: + continue + if row.get("is_user_defined"): + kept.append(row) + continue + if current_slug and slug == current_slug: + kept.append(row) + continue + if slug == "moa": + # MoA is a virtual routing mode, not an independently configured + # provider. Hide it from explicit-only pickers unless it is the + # current provider (handled above). + continue + if is_provider_explicitly_configured(slug): + kept.append(row) + return kept + + def _apply_picker_hints(rows: list[dict]) -> None: """Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row. diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d7ff480b390d..d8b9d4ac7af8 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4284,7 +4284,12 @@ _AUX_TASK_SLOTS: Tuple[str, ...] = ( @app.get("/api/model/options") -def get_model_options(profile: Optional[str] = None, refresh: bool = False): +def get_model_options( + profile: Optional[str] = None, + refresh: bool = False, + include_unconfigured: bool = False, + explicit_only: bool = False, +): """Return authenticated providers + their curated model lists. REST equivalent of the ``model.options`` JSON-RPC on tui_gateway, so the @@ -4303,18 +4308,15 @@ def get_model_options(profile: Optional[str] = None, refresh: bool = False): try: from hermes_cli.inventory import build_models_payload, load_picker_context - # include_unconfigured + picker_hints + canonical_order mirror the - # tui_gateway `model.options` JSON-RPC handler exactly, so every GUI - # surface fed by this endpoint (Settings → Model, the first-run - # onboarding picker) sees the SAME full provider universe `hermes model` - # exposes — not just the authenticated subset. Unconfigured providers - # come back as skeleton rows carrying `authenticated=False` + - # `auth_type`/`key_env`/`warning` so the GUI can render a setup - # affordance instead of hiding the provider entirely. + # Most desktop surfaces should only list providers the user has already + # configured. Onboarding opts into the full provider universe via + # include_unconfigured=1 so it can still render setup affordances for + # providers that are not yet authenticated. with _profile_scope(profile): return build_models_payload( load_picker_context(), - include_unconfigured=True, + explicit_only=bool(explicit_only), + include_unconfigured=bool(include_unconfigured), picker_hints=True, canonical_order=True, pricing=True, diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index 09a839f1c2e6..34c61aa13074 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -378,6 +378,44 @@ def test_include_unconfigured_skips_already_present_slugs(): assert or_rows[0]["models"] == ["m1"] # the authenticated row, not skeleton +def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_rows(): + rows = [ + {"slug": "openai-codex", "name": "OpenAI Codex", "models": ["gpt-5.4"], + "total_models": 1, "is_current": True, "is_user_defined": False, + "source": "hermes"}, + {"slug": "gemini", "name": "Gemini", "models": ["gemini-2.5-pro"], + "total_models": 1, "is_current": False, "is_user_defined": False, + "source": "built-in"}, + {"slug": "copilot", "name": "Copilot", "models": ["gpt-5.4"], + "total_models": 1, "is_current": False, "is_user_defined": False, + "source": "hermes"}, + {"slug": "nous", "name": "Nous", "models": ["anthropic/claude-sonnet-5"], + "total_models": 1, "is_current": False, "is_user_defined": False, + "source": "hermes"}, + {"slug": "custom:lab", "name": "Lab", "models": ["lab-1"], + "total_models": 1, "is_current": False, "is_user_defined": True, + "source": "user-config"}, + {"slug": "moa", "name": "MoA", "models": ["default"], + "total_models": 1, "is_current": False, "is_user_defined": False, + "source": "virtual"}, + ] + ctx = _empty_ctx(provider="openai-codex", model="gpt-5.4") + with ( + _list_auth_returning(rows), + patch( + "hermes_cli.auth.is_provider_explicitly_configured", + side_effect=lambda slug: slug == "gemini", + ), + ): + payload = build_models_payload(ctx, explicit_only=True) + + assert [row["slug"] for row in payload["providers"]] == [ + "openai-codex", + "gemini", + "custom:lab", + ] + + # ─── picker_hints ────────────────────────────────────────────────────── diff --git a/tests/hermes_cli/test_web_server_profile_unification.py b/tests/hermes_cli/test_web_server_profile_unification.py index a7fc21459955..7cc3ba8351d4 100644 --- a/tests/hermes_cli/test_web_server_profile_unification.py +++ b/tests/hermes_cli/test_web_server_profile_unification.py @@ -316,6 +316,36 @@ class TestProfileScopedModel: resp = client.get("/api/model/options", params={"profile": "ghost"}) assert resp.status_code == 404 + def test_model_options_hides_unconfigured_providers_by_default(self, client, monkeypatch): + calls = [] + + monkeypatch.setattr( + "hermes_cli.inventory.load_picker_context", + lambda: object(), + ) + + def _fake_build_models_payload(_ctx, **kwargs): + calls.append(kwargs) + return {"providers": [], "model": "", "provider": ""} + + monkeypatch.setattr( + "hermes_cli.inventory.build_models_payload", + _fake_build_models_payload, + ) + + resp = client.get("/api/model/options") + assert resp.status_code == 200 + assert calls[-1]["explicit_only"] is False + assert calls[-1]["include_unconfigured"] is False + + resp = client.get("/api/model/options", params={"explicit_only": "1"}) + assert resp.status_code == 200 + assert calls[-1]["explicit_only"] is True + + resp = client.get("/api/model/options", params={"include_unconfigured": "1"}) + assert resp.status_code == 200 + assert calls[-1]["include_unconfigured"] is True + def test_model_info_unknown_profile_404(self, client, isolated_profiles): """Regression: the broad except used to convert the 404 into a 200 with empty model info ("no model set" — silently wrong).""" diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 3286363dfd23..5e4dfea99d93 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -5984,6 +5984,52 @@ def test_model_options_propagates_list_exception(monkeypatch): assert "catalog blew up" in resp["error"]["message"] +def test_model_options_hides_unconfigured_providers_by_default(monkeypatch): + from hermes_cli.inventory import ConfigContext + + calls = [] + + monkeypatch.setattr(server, "_resolve_model", lambda: "") + monkeypatch.setattr( + "hermes_cli.inventory.load_picker_context", + lambda: ConfigContext( + current_provider="", + current_model="", + current_base_url="", + user_providers={}, + custom_providers=[], + ), + ) + + def _fake_build_models_payload(_ctx, **kwargs): + calls.append(kwargs) + return {"providers": [], "model": "", "provider": ""} + + monkeypatch.setattr( + "hermes_cli.inventory.build_models_payload", + _fake_build_models_payload, + ) + + resp = server._methods["model.options"](99, {"session_id": ""}) + assert "result" in resp, resp + assert calls[-1]["explicit_only"] is False + assert calls[-1]["include_unconfigured"] is False + + resp = server._methods["model.options"]( + 100, + {"session_id": "", "explicit_only": True}, + ) + assert "result" in resp, resp + assert calls[-1]["explicit_only"] is True + + resp = server._methods["model.options"]( + 101, + {"session_id": "", "include_unconfigured": True}, + ) + assert "result" in resp, resp + assert calls[-1]["include_unconfigured"] is True + + # --------------------------------------------------------------------------- # prompt.submit — auto-title # --------------------------------------------------------------------------- diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 8845128f9a56..3c0e8372d684 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -12398,17 +12398,18 @@ def _(rid, params: dict) -> dict: ), current_base_url=getattr(agent, "base_url", "") if agent else "", ) - # picker_hints + canonical_order produce the TUI's required shape: + # picker_hints + canonical_order produce the TUI/desktop picker shape: # `authenticated`/`auth_type`/`key_env`/`warning` per row, in - # CANONICAL_PROVIDERS declaration order. include_unconfigured=True - # so the picker can show the full provider universe (with the - # setup-hint warning attached) instead of only authed rows. + # CANONICAL_PROVIDERS declaration order. Desktop pickers default to the + # configured subset; callers that need setup affordances can pass + # include_unconfigured=true explicitly. # Curated model lists are preserved — list_authenticated_providers # populates `models` from the curated catalog, not provider_model_ids # (which would pull non-agentic models like TTS/embeddings/etc.). payload = build_models_payload( ctx, - include_unconfigured=True, + explicit_only=bool(params.get("explicit_only")), + include_unconfigured=bool(params.get("include_unconfigured")), picker_hints=True, canonical_order=True, pricing=True, diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 8752dd371a23..149047e4002e 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -60,7 +60,12 @@ export function ModelPicker({ useEffect(() => { gw.request('model.options', { ...(sessionId ? { session_id: sessionId } : {}), - ...(initialRefresh ? { refresh: true } : {}) + ...(initialRefresh ? { refresh: true } : {}), + // The TUI picker shows the full provider universe with setup + // affordances ("paste KEY to activate"), so opt into unconfigured + // rows — the backend now defaults to the configured subset for + // desktop chat pickers (#56974). + include_unconfigured: true }) .then(raw => { const r = asRpcResult(raw) diff --git a/web/src/components/ModelPickerDialog.tsx b/web/src/components/ModelPickerDialog.tsx index 554288e60ecb..b05e389bdb40 100644 --- a/web/src/components/ModelPickerDialog.tsx +++ b/web/src/components/ModelPickerDialog.tsx @@ -138,6 +138,10 @@ export function ModelPickerDialog(props: Props) { { ...(sessionId ? { session_id: sessionId } : {}), ...(refresh ? { refresh: true } : {}), + // Dashboard picker mirrors the TUI: full provider universe with + // setup warnings. The backend now defaults to the configured + // subset (#56974), so opt into unconfigured rows explicitly. + include_unconfigured: true, }, ); diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index eff359518c17..4da9234ac5d7 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -22,7 +22,7 @@ describe("api.getModelOptions", () => { await api.getModelOptions({ refresh: true }); expect(fetchMock).toHaveBeenCalledWith( - "/api/model/options?refresh=1", + "/api/model/options?refresh=1&include_unconfigured=1", expect.objectContaining({ credentials: "include" }), ); }); @@ -41,7 +41,7 @@ describe("api.getModelOptions", () => { await api.getModelOptions({ profile: "default", refresh: true }); expect(fetchMock).toHaveBeenCalledWith( - "/api/model/options?profile=default&refresh=1", + "/api/model/options?profile=default&refresh=1&include_unconfigured=1", expect.objectContaining({ credentials: "include" }), ); }); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1f14ee68b34d..25128b306a44 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -487,6 +487,11 @@ export const api = { const qs = new URLSearchParams(); if (profile) qs.set("profile", profile); if (refresh) qs.set("refresh", "1"); + // Dashboard surfaces (Models page, profile builder, cron) are + // management/setup UIs: keep the full provider universe with setup + // affordances. The endpoint now defaults to the configured subset for + // desktop chat pickers (#56974), so opt in explicitly here. + qs.set("include_unconfigured", "1"); const suffix = qs.toString() ? `?${qs.toString()}` : ""; return fetchJSON(`/api/model/options${suffix}`); }, From a0b14a31267db9a22c15d43ccc7408e4013a75f5 Mon Sep 17 00:00:00 2001 From: Ronald Reis Date: Thu, 2 Jul 2026 14:55:43 +0100 Subject: [PATCH 147/610] chore: map Ronald contributor email --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 3b0c9845c358..7a9fdc4ac8d3 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -90,6 +90,7 @@ AUTHOR_MAP = { "aqdrgg19@gmail.com": "VolodymyrBg", # PR #2861 salvage (webhook: drop the unused full request payload from retained _delivery_info entries — up to ~1MB dead weight per delivery for the 1h idempotency TTL) "ohyes9711@gmail.com": "CharmingGroot", # PR #2794 salvage (email: guard msg_data[0][1] against malformed IMAP fetch structures so one bad response can't abort the batch and permanently lose seen-marked messages; Message-ID domain falls back to localhost when EMAIL_ADDRESS lacks '@') "sahibzada@fastino.ai": "sahibzada-allahyar", # PR #39227 salvage (desktop: configured terminal.cwd overrides a stale remembered workspace-cwd localStorage value when no session is active; #38855) + "ronaldrj@gmail.com": "rarf", # PR #56966 salvage (desktop chat model picker: hide implicitly discovered providers unless explicitly configured) "jvsantos.cunha@gmail.com": "plcunha", # PR #55300 salvage (gateway: record child gateway peer metadata after a compression session-id rotation and repoint stale sessions.json compression-parent entries to the recovered live child; consolidated in the compression-routing-integrity salvage) "jakepresent1@gmail.com": "jakepresent", # PR #55721 salvage (gateway: identity-guard stale in-flight compression splits — a late run may publish its compressed child only if its run generation is still current and the session key still points at the run's original parent, so an old run can't overwrite a newer /new or moved binding) "gumclaw@gumroad.com": "gumclaw", # PR #57322 salvage (gateway: close per-delivery webhook sessions on completion so prune_sessions can reap them — fixes unbounded state.db growth from unprunable ended_at=NULL webhook rows) From f304f412665b6b7a3af766879912288e02444161 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:15:40 -0700 Subject: [PATCH 148/610] fix: harden explicit-provider gate for stale env-seeded pool entries + non-desktop picker opt-ins Follow-up on the #56966 salvage: - is_provider_explicitly_configured(): an env-seeded credential-pool entry only counts as explicit while its env var still resolves to a usable secret. A stale auth.json entry left behind after the user deletes the var no longer keeps the provider in the picker forever (#55790). - TUI modelPicker + dashboard ModelPickerDialog/api.getModelOptions pass include_unconfigured=true explicitly, preserving their full-universe setup-affordance behavior now that the backend defaults to the configured subset. - desktop lib/model-options.ts routes explicit_only through the shared requestModelOptions() helper (added on main after the PR branched). - regression tests for ambient (gh_cli) pool sources, explicit manual/ device-code sources, and stale vs live env-seeded entries. --- tests/hermes_cli/test_auth_provider_gate.py | 89 +++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/tests/hermes_cli/test_auth_provider_gate.py b/tests/hermes_cli/test_auth_provider_gate.py index 561009d096ce..d379bdd262d4 100644 --- a/tests/hermes_cli/test_auth_provider_gate.py +++ b/tests/hermes_cli/test_auth_provider_gate.py @@ -82,3 +82,92 @@ def test_claude_code_oauth_token_does_not_count_as_explicit(tmp_path, monkeypatc from hermes_cli.auth import is_provider_explicitly_configured assert is_provider_explicitly_configured("anthropic") is False + + +def test_ambient_pool_source_does_not_count_as_explicit(tmp_path, monkeypatch): + """gh_cli-seeded Copilot pool entries are ambient, not explicit config (#56974).""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + _write_auth_store(tmp_path, { + "version": 1, + "providers": {}, + "active_provider": None, + "credential_pool": { + "copilot": [{ + "id": "abc123", + "source": "gh_cli", + "auth_type": "api_key", + "access_token": "ghu_sometoken", + }], + }, + }) + + from hermes_cli.auth import is_provider_explicitly_configured + assert is_provider_explicitly_configured("copilot") is False + + +def test_explicit_pool_source_counts_as_explicit(tmp_path, monkeypatch): + """manual / device_code / PKCE pool entries reflect explicit Hermes flows.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store(tmp_path, { + "version": 1, + "providers": {}, + "active_provider": None, + "credential_pool": { + "anthropic": [{ + "id": "def456", + "source": "manual:key-1", + "auth_type": "api_key", + "access_token": "sk-ant-api03-key", + }], + }, + }) + + from hermes_cli.auth import is_provider_explicitly_configured + assert is_provider_explicitly_configured("anthropic") is True + + +def test_stale_env_pool_entry_does_not_count_when_var_unset(tmp_path, monkeypatch): + """An env-seeded pool entry left in auth.json after the env var was removed + must not mark the provider configured (#55790): the picker showed removed + providers forever because the record existed even though no secret resolves.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + _write_auth_store(tmp_path, { + "version": 1, + "providers": {}, + "active_provider": None, + "credential_pool": { + "deepseek": [{ + "id": "aaa111", + "source": "env:DEEPSEEK_API_KEY", + "auth_type": "api_key", + }], + }, + }) + + from hermes_cli.auth import is_provider_explicitly_configured + assert is_provider_explicitly_configured("deepseek") is False + + +def test_env_pool_entry_counts_when_var_still_resolves(tmp_path, monkeypatch): + """The same env-seeded pool entry IS explicit while the var still resolves.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-deepseek-realkey-123456") + _write_auth_store(tmp_path, { + "version": 1, + "providers": {}, + "active_provider": None, + "credential_pool": { + "deepseek": [{ + "id": "aaa111", + "source": "env:DEEPSEEK_API_KEY", + "auth_type": "api_key", + }], + }, + }) + + from hermes_cli.auth import is_provider_explicitly_configured + assert is_provider_explicitly_configured("deepseek") is True From 36308f0667ae141546a5f4c8dcaee346cc3f0f3c Mon Sep 17 00:00:00 2001 From: doncazper Date: Sun, 5 Jul 2026 14:21:25 -0700 Subject: [PATCH 149/610] feat(plugins): pass approve rule keys to approval gate --- agent/agent_runtime_helpers.py | 8 +- agent/tool_executor.py | 8 +- hermes_cli/plugins.py | 159 ++++++++- model_tools.py | 19 +- tests/hermes_cli/test_plugins.py | 165 ++++++++++ tests/run_agent/test_run_agent.py | 30 +- .../test_tool_call_guardrail_runtime.py | 2 +- tests/tools/test_request_tool_approval.py | 167 ++++++++++ tools/approval.py | 307 ++++++++++++++---- 9 files changed, 762 insertions(+), 103 deletions(-) create mode 100644 tests/tools/test_request_tool_approval.py diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index cbccc683da58..6f57f83e9774 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -2115,12 +2115,12 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i except Exception as _mw_err: logger.debug("tool_request middleware error: %s", _mw_err) - # Check plugin hooks for a block directive before executing anything. + # Check plugin hooks for a block or approval directive before executing. block_message: Optional[str] = None if not pre_tool_block_checked: try: - from hermes_cli.plugins import get_pre_tool_call_block_message - block_message = get_pre_tool_call_block_message( + from hermes_cli.plugins import resolve_pre_tool_block + block_message = resolve_pre_tool_block( function_name, function_args, task_id=effective_task_id or "", @@ -2131,7 +2131,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i middleware_trace=list(_tool_middleware_trace), ) except Exception: - pass + block_message = None if block_message is not None: result = json.dumps({"error": block_message}, ensure_ascii=False) try: diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 44b9a367c90e..9688f5d3dc6a 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -415,8 +415,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe ) else: try: - from hermes_cli.plugins import get_pre_tool_call_block_message - block_message = get_pre_tool_call_block_message( + from hermes_cli.plugins import resolve_pre_tool_block + block_message = resolve_pre_tool_block( function_name, function_args, task_id=effective_task_id or "", @@ -1034,8 +1034,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe _block_error_type = "tool_scope_block" else: try: - from hermes_cli.plugins import get_pre_tool_call_block_message - _block_msg = get_pre_tool_call_block_message( + from hermes_cli.plugins import resolve_pre_tool_block + _block_msg = resolve_pre_tool_block( function_name, function_args, task_id=effective_task_id or "", diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 452eb6b7941d..ea0b8ea2ffe1 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -2077,6 +2077,13 @@ def has_hook(hook_name: str) -> bool: _thread_tool_whitelist = threading.local() +@dataclass(frozen=True) +class _PreToolCallDirective: + action: Optional[str] = None + message: Optional[str] = None + rule_key: Optional[str] = None + + def set_thread_tool_whitelist( allowed: Optional[Set[str]], deny_msg_fmt: str = "Tool '{tool_name}' denied: not in this thread's tool whitelist", @@ -2089,7 +2096,7 @@ def clear_thread_tool_whitelist() -> None: _thread_tool_whitelist.allowed = None -def get_pre_tool_call_block_message( +def _get_pre_tool_call_directive_details( tool_name: str, args: Optional[Dict[str, Any]], task_id: str = "", @@ -2098,22 +2105,40 @@ def get_pre_tool_call_block_message( turn_id: str = "", api_request_id: str = "", middleware_trace: Optional[List[Dict[str, Any]]] = None, -) -> Optional[str]: - """Check ``pre_tool_call`` hooks for a blocking directive. +) -> _PreToolCallDirective: + """Check ``pre_tool_call`` hooks for a blocking or approval directive. Plugins that need to enforce policy (rate limiting, security - restrictions, approval workflows) can return:: + restrictions, approval workflows) can return one of:: - {"action": "block", "message": "Reason the tool was blocked"} + {"action": "block", "message": "Reason the tool was blocked"} + {"action": "approve", "message": "Why this needs human confirmation"} + {"action": "approve", "message": "...", "rule_key": "write_file:ssh"} - from their ``pre_tool_call`` callback. The first valid block - directive wins. Invalid or irrelevant hook return values are - silently ignored so existing observer-only hooks are unaffected. + from their ``pre_tool_call`` callback. + + - ``block`` vetoes the tool call outright (the message becomes the tool + result the model sees). + - ``approve`` ESCALATES to the existing human-approval gate + (``prompt_dangerous_approval`` on CLI, the approval callback on the + gateway) — the same mechanism Tier-2 dangerous shell patterns use. + This lets a plugin require a human ``[o]nce/[s]ession/[a]lways/[d]eny`` + decision on ANY tool, not just terminal command strings. The caller is + responsible for invoking the gate (see + :func:`tools.approval.request_tool_approval`). + - ``rule_key`` is optional and only honored for ``approve`` directives. It + lets plugins choose the allowlist grain for `[a]lways` approvals. + + The first valid directive wins. Invalid or irrelevant hook return values + are silently ignored so existing observer-only hooks are unaffected. """ allowed = getattr(_thread_tool_whitelist, "allowed", None) if allowed is not None and tool_name not in allowed: fmt = getattr(_thread_tool_whitelist, "fmt", "Tool '{tool_name}' denied") - return fmt.format(tool_name=tool_name) + return _PreToolCallDirective( + action="block", + message=fmt.format(tool_name=tool_name), + ) hook_results = invoke_hook( "pre_tool_call", @@ -2130,12 +2155,122 @@ def get_pre_tool_call_block_message( for result in hook_results: if not isinstance(result, dict): continue - if result.get("action") != "block": + action = result.get("action") + if action not in ("block", "approve"): continue message = result.get("message") - if isinstance(message, str) and message: - return message + message = message if isinstance(message, str) and message else None + # A block directive requires a message (it becomes the tool result); + # an approve directive can carry an optional reason. + if action == "block" and not message: + continue + rule_key = result.get("rule_key") if action == "approve" else None + rule_key = rule_key.strip() if isinstance(rule_key, str) else None + if not rule_key: + rule_key = None + return _PreToolCallDirective(action=action, message=message, rule_key=rule_key) + return _PreToolCallDirective() + + +def get_pre_tool_call_directive( + tool_name: str, + args: Optional[Dict[str, Any]], + task_id: str = "", + session_id: str = "", + tool_call_id: str = "", + turn_id: str = "", + api_request_id: str = "", + middleware_trace: Optional[List[Dict[str, Any]]] = None, +) -> tuple[Optional[str], Optional[str]]: + """Check ``pre_tool_call`` hooks for a blocking or approval directive. + + Backward-compatible public helper: returns ``(directive, message)`` where + ``directive`` is ``"block"``, ``"approve"``, or ``None``. Internal callers + that need approve-specific metadata use + :func:`_get_pre_tool_call_directive_details`. + """ + details = _get_pre_tool_call_directive_details( + tool_name, args, task_id=task_id, session_id=session_id, + tool_call_id=tool_call_id, turn_id=turn_id, + api_request_id=api_request_id, middleware_trace=middleware_trace, + ) + return (details.action, details.message) + + +def get_pre_tool_call_block_message( + tool_name: str, + args: Optional[Dict[str, Any]], + task_id: str = "", + session_id: str = "", + tool_call_id: str = "", + turn_id: str = "", + api_request_id: str = "", + middleware_trace: Optional[List[Dict[str, Any]]] = None, +) -> Optional[str]: + """Back-compat shim: return only a ``block`` message (or ``None``). + + Deprecated in favor of :func:`get_pre_tool_call_directive`, which also + surfaces the ``approve`` escalation directive. Kept so any external caller + importing the old name keeps working; ``approve`` directives are invisible + to this shim (it only reports blocks). + """ + directive, message = get_pre_tool_call_directive( + tool_name, args, task_id=task_id, session_id=session_id, + tool_call_id=tool_call_id, turn_id=turn_id, + api_request_id=api_request_id, middleware_trace=middleware_trace, + ) + return message if directive == "block" else None + + +def resolve_pre_tool_block( + tool_name: str, + args: Optional[Dict[str, Any]], + task_id: str = "", + session_id: str = "", + tool_call_id: str = "", + turn_id: str = "", + api_request_id: str = "", + middleware_trace: Optional[List[Dict[str, Any]]] = None, +) -> Optional[str]: + """Resolve the pre_tool_call directive to a final block message (or None). + + Single entry point for every tool-dispatch site: fetches the plugin + directive and, for an ``approve`` escalation, invokes the human-approval + gate (:func:`tools.approval.request_tool_approval`). Returns the message + the tool result should carry when the call is blocked, or ``None`` when + the call may proceed. + + Centralizing this keeps the security-critical fail-closed logic in ONE + place instead of copy-pasted across the concurrent/sequential/helper + dispatch paths: an ``approve`` directive whose gate errors, denies, or + times out is fail-closed to a block; ``block`` blocks with its message; + anything else proceeds. + """ + details = _get_pre_tool_call_directive_details( + tool_name, args, task_id=task_id, session_id=session_id, + tool_call_id=tool_call_id, turn_id=turn_id, + api_request_id=api_request_id, middleware_trace=middleware_trace, + ) + if details.action == "block": + return details.message + if details.action == "approve": + try: + from tools.approval import request_tool_approval + result = request_tool_approval( + tool_name, + details.message or "", + rule_key=details.rule_key or tool_name, + ) + except Exception: + # Fail-closed: if the gate itself errors, block rather than + # silently execute an action a plugin flagged for approval. + return f"BLOCKED: plugin approval gate failed for {tool_name}" + if not result.get("approved"): + return str( + result.get("message") + or f"BLOCKED: plugin approval required for {tool_name}" + ) return None diff --git a/model_tools.py b/model_tools.py index 7aed8acd5897..ed236cb2fa4c 100644 --- a/model_tools.py +++ b/model_tools.py @@ -1161,21 +1161,22 @@ def handle_function_call( if function_name in _AGENT_LOOP_TOOLS: return json.dumps({"error": f"{function_name} must be handled by the agent loop"}) - # Check plugin hooks for a block directive (unless caller already - # checked — e.g. run_agent._invoke_tool passes skip=True to + # Check plugin hooks for a block/approve directive (unless caller + # already checked — e.g. run_agent._invoke_tool passes skip=True to # avoid double-firing the hook). # # Single-fire contract: pre_tool_call fires exactly once per tool - # execution. get_pre_tool_call_block_message() internally calls - # invoke_hook("pre_tool_call", ...) and returns the first block - # directive (if any), so observer plugins see the hook on that same - # pass. When skip=True, the caller already fired it — do nothing - # here. + # execution. resolve_pre_tool_block() internally calls + # invoke_hook("pre_tool_call", ...) once and returns the block message + # for a `block` directive OR for an `approve` directive whose human + # gate denied/timed-out/errored (fail-closed). Observer plugins see + # the hook on that same pass. When skip=True, the caller already + # fired it — do nothing here. if not skip_pre_tool_call_hook: block_message: Optional[str] = None try: - from hermes_cli.plugins import get_pre_tool_call_block_message - block_message = get_pre_tool_call_block_message( + from hermes_cli.plugins import resolve_pre_tool_block + block_message = resolve_pre_tool_block( function_name, function_args, task_id=task_id or "", diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 0df9790c31ab..3fdb6d1812d2 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -859,6 +859,171 @@ class TestPreToolCallBlocking: assert get_pre_tool_call_block_message("terminal", {}) == "first blocker" +class TestPreToolCallDirective: + """Tests for the extended (block | approve) directive helper.""" + + def test_approve_directive_returned(self, monkeypatch): + from hermes_cli.plugins import get_pre_tool_call_directive + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: [ + {"action": "approve", "message": "needs human ok"} + ], + ) + assert get_pre_tool_call_directive("write_file", {}) == ( + "approve", "needs human ok") + + def test_approve_without_message_is_valid(self, monkeypatch): + """approve may omit a message (block may not).""" + from hermes_cli.plugins import get_pre_tool_call_directive + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: [{"action": "approve"}], + ) + assert get_pre_tool_call_directive("write_file", {}) == ("approve", None) + + def test_block_still_requires_message(self, monkeypatch): + from hermes_cli.plugins import get_pre_tool_call_directive + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: [{"action": "block"}], + ) + assert get_pre_tool_call_directive("terminal", {}) == (None, None) + + def test_first_directive_wins_across_actions(self, monkeypatch): + from hermes_cli.plugins import get_pre_tool_call_directive + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: [ + {"action": "approve", "message": "gate first"}, + {"action": "block", "message": "block second"}, + ], + ) + assert get_pre_tool_call_directive("terminal", {}) == ( + "approve", "gate first") + + def test_shim_ignores_approve(self, monkeypatch): + """Back-compat shim only reports block, never approve.""" + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: [ + {"action": "approve", "message": "gate"} + ], + ) + assert get_pre_tool_call_block_message("write_file", {}) is None + + +class TestResolvePreToolBlock: + """Tests for the single dispatch-site chokepoint that resolves a + directive (incl. the approve→gate escalation) to a block message.""" + + def test_block_returns_message(self, monkeypatch): + from hermes_cli.plugins import resolve_pre_tool_block + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: [{"action": "block", "message": "no"}], + ) + assert resolve_pre_tool_block("terminal", {}) == "no" + + def test_no_directive_returns_none(self, monkeypatch): + from hermes_cli.plugins import resolve_pre_tool_block + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", lambda hook_name, **kwargs: []) + assert resolve_pre_tool_block("terminal", {}) is None + + def test_approve_denied_blocks(self, monkeypatch): + from hermes_cli.plugins import resolve_pre_tool_block + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: [{"action": "approve", "message": "why"}], + ) + monkeypatch.setattr( + "tools.approval.request_tool_approval", + lambda *a, **k: {"approved": False, "message": "user denied it"}, + ) + assert resolve_pre_tool_block("write_file", {}) == "user denied it" + + def test_approve_granted_allows(self, monkeypatch): + from hermes_cli.plugins import resolve_pre_tool_block + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: [{"action": "approve", "message": "why"}], + ) + monkeypatch.setattr( + "tools.approval.request_tool_approval", + lambda *a, **k: {"approved": True, "message": None}, + ) + assert resolve_pre_tool_block("write_file", {}) is None + + def test_approve_passes_plugin_rule_key_to_gate(self, monkeypatch): + from hermes_cli.plugins import resolve_pre_tool_block + + seen = {} + + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: [ + { + "action": "approve", + "message": "why", + "rule_key": "write_file:ssh", + } + ], + ) + + def _approve(tool_name, reason, **kwargs): + seen["tool_name"] = tool_name + seen["reason"] = reason + seen["rule_key"] = kwargs.get("rule_key") + return {"approved": True, "message": None} + + monkeypatch.setattr("tools.approval.request_tool_approval", _approve) + + assert resolve_pre_tool_block("write_file", {}) is None + assert seen == { + "tool_name": "write_file", + "reason": "why", + "rule_key": "write_file:ssh", + } + + @pytest.mark.parametrize("rule_key", [None, "", " ", 123, object()]) + def test_approve_falls_back_to_tool_name_without_valid_rule_key( + self, monkeypatch, rule_key + ): + from hermes_cli.plugins import resolve_pre_tool_block + + seen = {} + directive = {"action": "approve", "message": "why"} + if rule_key is not None: + directive["rule_key"] = rule_key + + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: [directive], + ) + + def _approve(tool_name, reason, **kwargs): + seen["rule_key"] = kwargs.get("rule_key") + return {"approved": True, "message": None} + + monkeypatch.setattr("tools.approval.request_tool_approval", _approve) + + assert resolve_pre_tool_block("write_file", {}) is None + assert seen["rule_key"] == "write_file" + + def test_approve_gate_exception_fails_closed(self, monkeypatch): + from hermes_cli.plugins import resolve_pre_tool_block + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: [{"action": "approve", "message": "why"}], + ) + def _boom(*a, **k): + raise RuntimeError("gate crashed") + monkeypatch.setattr("tools.approval.request_tool_approval", _boom) + msg = resolve_pre_tool_block("terminal", {}) + assert msg is not None and "gate failed" in msg # fail-closed + + class TestGetPreVerifyContinueMessage: """`pre_verify` directive aggregation — mirrors the pre_tool_call block path.""" diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index da8a446bf8d8..171a7c11255d 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -2966,7 +2966,7 @@ class TestConcurrentToolExecution: """Agent-owned tool paths should close observer tool spans.""" hook_calls = [] monkeypatch.setattr( - "hermes_cli.plugins.get_pre_tool_call_block_message", + "hermes_cli.plugins.resolve_pre_tool_block", lambda *args, **kwargs: None, ) monkeypatch.setattr( @@ -2990,7 +2990,7 @@ class TestConcurrentToolExecution: def test_invoke_tool_blocked_returns_error_and_skips_execution(self, agent, monkeypatch): """_invoke_tool should return error JSON when a plugin blocks the tool.""" monkeypatch.setattr( - "hermes_cli.plugins.get_pre_tool_call_block_message", + "hermes_cli.plugins.resolve_pre_tool_block", lambda *args, **kwargs: "Blocked by test policy", ) with patch("tools.todo_tool.todo_tool", side_effect=AssertionError("should not run")) as mock_todo: @@ -3002,7 +3002,7 @@ class TestConcurrentToolExecution: def test_invoke_tool_blocked_skips_handle_function_call(self, agent, monkeypatch): """Blocked registry tools should not reach handle_function_call.""" monkeypatch.setattr( - "hermes_cli.plugins.get_pre_tool_call_block_message", + "hermes_cli.plugins.resolve_pre_tool_block", lambda *args, **kwargs: "Blocked", ) with patch("run_agent.handle_function_call", side_effect=AssertionError("should not run")): @@ -3019,7 +3019,7 @@ class TestConcurrentToolExecution: messages = [] monkeypatch.setattr( - "hermes_cli.plugins.get_pre_tool_call_block_message", + "hermes_cli.plugins.resolve_pre_tool_block", lambda *args, **kwargs: "Blocked by policy", ) agent._checkpoint_mgr.enabled = True @@ -3049,7 +3049,7 @@ class TestConcurrentToolExecution: hook_calls = [] monkeypatch.setattr( - "hermes_cli.plugins.get_pre_tool_call_block_message", + "hermes_cli.plugins.resolve_pre_tool_block", lambda *args, **kwargs: "Blocked by policy", ) monkeypatch.setattr( @@ -3076,7 +3076,7 @@ class TestConcurrentToolExecution: hook_calls = [] monkeypatch.setattr( - "hermes_cli.plugins.get_pre_tool_call_block_message", + "hermes_cli.plugins.resolve_pre_tool_block", lambda *args, **kwargs: None, ) monkeypatch.setattr( @@ -3123,7 +3123,7 @@ class TestConcurrentToolExecution: lambda kind, **kwargs: [request_middleware(**kwargs)] if kind == "tool_request" else [], ) monkeypatch.setattr( - "hermes_cli.plugins.get_pre_tool_call_block_message", + "hermes_cli.plugins.resolve_pre_tool_block", lambda *args, **kwargs: None, ) monkeypatch.setattr( @@ -3161,7 +3161,7 @@ class TestConcurrentToolExecution: lambda kind, **kwargs: [request_middleware(**kwargs)] if kind == "tool_request" else [], ) monkeypatch.setattr( - "hermes_cli.plugins.get_pre_tool_call_block_message", + "hermes_cli.plugins.resolve_pre_tool_block", lambda *args, **kwargs: None, ) monkeypatch.setattr( @@ -3196,7 +3196,7 @@ class TestConcurrentToolExecution: """Blocked memory tool should not reset the nudge counter.""" agent._turns_since_memory = 5 monkeypatch.setattr( - "hermes_cli.plugins.get_pre_tool_call_block_message", + "hermes_cli.plugins.resolve_pre_tool_block", lambda *args, **kwargs: "Blocked", ) with patch("tools.memory_tool.memory_tool", side_effect=AssertionError("should not run")): @@ -3209,7 +3209,7 @@ class TestConcurrentToolExecution: def test_invoke_tool_memory_remove_notifies_provider_with_old_text(self, agent, monkeypatch): monkeypatch.setattr( - "hermes_cli.plugins.get_pre_tool_call_block_message", + "hermes_cli.plugins.resolve_pre_tool_block", lambda *args, **kwargs: None, ) calls = [] @@ -3241,7 +3241,7 @@ class TestConcurrentToolExecution: def test_invoke_tool_memory_failed_remove_skips_provider_notification(self, agent, monkeypatch): monkeypatch.setattr( - "hermes_cli.plugins.get_pre_tool_call_block_message", + "hermes_cli.plugins.resolve_pre_tool_block", lambda *args, **kwargs: None, ) notify = MagicMock(side_effect=AssertionError("should not notify")) @@ -3281,7 +3281,7 @@ class TestConcurrentToolExecution: messages = [] monkeypatch.setattr( - "hermes_cli.plugins.get_pre_tool_call_block_message", + "hermes_cli.plugins.resolve_pre_tool_block", lambda *args, **kwargs: "Blocked" if args[0] == "write_file" else None, ) @@ -3308,7 +3308,7 @@ class TestConcurrentToolExecution: messages = [] monkeypatch.setattr( - "hermes_cli.plugins.get_pre_tool_call_block_message", + "hermes_cli.plugins.resolve_pre_tool_block", lambda *args, **kwargs: "Blocked" if args[0] == "patch" else None, ) @@ -3335,7 +3335,7 @@ class TestConcurrentToolExecution: messages = [] monkeypatch.setattr( - "hermes_cli.plugins.get_pre_tool_call_block_message", + "hermes_cli.plugins.resolve_pre_tool_block", lambda *args, **kwargs: "Blocked" if args[0] == "terminal" else None, ) @@ -3369,7 +3369,7 @@ class TestConcurrentToolExecution: return "Blocked" if call_count["n"] == 1 else None monkeypatch.setattr( - "hermes_cli.plugins.get_pre_tool_call_block_message", + "hermes_cli.plugins.resolve_pre_tool_block", block_first_only, ) diff --git a/tests/run_agent/test_tool_call_guardrail_runtime.py b/tests/run_agent/test_tool_call_guardrail_runtime.py index e7ab376281ad..36ced43c7956 100644 --- a/tests/run_agent/test_tool_call_guardrail_runtime.py +++ b/tests/run_agent/test_tool_call_guardrail_runtime.py @@ -228,7 +228,7 @@ def test_plugin_pre_tool_block_wins_without_counting_as_toolguard_block(): messages = [] with ( - patch("hermes_cli.plugins.get_pre_tool_call_block_message", return_value="plugin policy"), + patch("hermes_cli.plugins.resolve_pre_tool_block", return_value="plugin policy"), patch("run_agent.handle_function_call", return_value="SHOULD_NOT_RUN") as mock_hfc, ): agent._execute_tool_calls_sequential(msg, messages, "task-1") diff --git a/tests/tools/test_request_tool_approval.py b/tests/tools/test_request_tool_approval.py new file mode 100644 index 000000000000..16414fc054c5 --- /dev/null +++ b/tests/tools/test_request_tool_approval.py @@ -0,0 +1,167 @@ +"""Tests for tools.approval.request_tool_approval — the plugin pre_tool_call +``{"action": "approve"}`` escalation into the human-approval gate. + +These verify that a plugin-driven approval reuses the SAME machinery as a +Tier-2 dangerous-command match: session/permanent allowlist, the CLI prompt, +the gateway submit_pending path, cron_mode, and fail-closed timeouts. +""" + +import pytest + +import tools.approval as approval +from tools.approval import request_tool_approval + + +@pytest.fixture(autouse=True) +def _isolate_approval_state(monkeypatch): + """Give each test a clean session key and empty allowlists.""" + monkeypatch.setattr( + approval, "get_current_session_key", + lambda default="default": "test-session", + ) + # Empty session + permanent approval stores so nothing pre-approves. + monkeypatch.setattr(approval, "is_approved", lambda sk, pk: False) + # Not a yolo session (the shared gate checks this first). + monkeypatch.setattr(approval, "is_current_session_yolo_enabled", lambda: False) + monkeypatch.setattr(approval, "_YOLO_MODE_FROZEN", False, raising=False) + # No thread-registered CLI callback by default. + monkeypatch.setattr( + "tools.terminal_tool._get_approval_callback", lambda: None, raising=False + ) + yield + + +class TestRequestToolApproval: + def test_session_cached_approval_short_circuits(self, monkeypatch): + monkeypatch.setattr(approval, "is_approved", lambda sk, pk: True) + # Should NOT prompt at all. + monkeypatch.setattr( + approval, "prompt_dangerous_approval", + lambda *a, **k: pytest.fail("should not prompt when already approved"), + ) + res = request_tool_approval("write_file", "sensitive path", rule_key="ssh") + assert res == {"approved": True, "message": None} + + def test_cli_approve_once(self, monkeypatch): + monkeypatch.setattr(approval, "_is_interactive_cli", lambda: True) + monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False) + monkeypatch.setattr(approval, "prompt_dangerous_approval", lambda *a, **k: "once") + res = request_tool_approval("write_file", "writing ~/.ssh/authorized_keys") + assert res["approved"] is True + + def test_cli_deny_blocks(self, monkeypatch): + monkeypatch.setattr(approval, "_is_interactive_cli", lambda: True) + monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False) + monkeypatch.setattr(approval, "prompt_dangerous_approval", lambda *a, **k: "deny") + res = request_tool_approval("terminal", "curl PUT to external API") + assert res["approved"] is False + assert "denied" in res["message"].lower() + assert res["pattern_key"].startswith("plugin_rule:") + + def test_cli_session_persists_session_only(self, monkeypatch): + monkeypatch.setattr(approval, "_is_interactive_cli", lambda: True) + monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False) + monkeypatch.setattr(approval, "prompt_dangerous_approval", lambda *a, **k: "session") + calls = {"session": [], "permanent": []} + monkeypatch.setattr(approval, "approve_session", + lambda sk, pk: calls["session"].append(pk)) + monkeypatch.setattr(approval, "approve_permanent", + lambda pk: calls["permanent"].append(pk)) + monkeypatch.setattr(approval, "save_permanent_allowlist", lambda x: None) + res = request_tool_approval("write_file", "reason", rule_key="ssh-writes") + assert res["approved"] is True + assert calls["session"] == ["plugin_rule:ssh-writes"] + assert calls["permanent"] == [] # session != always + + def test_cli_always_persists_permanent(self, monkeypatch): + monkeypatch.setattr(approval, "_is_interactive_cli", lambda: True) + monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False) + monkeypatch.setattr(approval, "prompt_dangerous_approval", lambda *a, **k: "always") + persisted = {} + monkeypatch.setattr(approval, "approve_session", lambda sk, pk: None) + monkeypatch.setattr(approval, "approve_permanent", + lambda pk: persisted.setdefault("key", pk)) + monkeypatch.setattr(approval, "save_permanent_allowlist", + lambda x: persisted.setdefault("saved", True)) + res = request_tool_approval("write_file", "reason", rule_key="ssh-writes") + assert res["approved"] is True + assert persisted["key"] == "plugin_rule:ssh-writes" + assert persisted["saved"] is True + + def test_gateway_path_submits_pending_and_defers(self, monkeypatch): + monkeypatch.setattr(approval, "_is_interactive_cli", lambda: False) + monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: True) + submitted = {} + monkeypatch.setattr(approval, "submit_pending", + lambda sk, data: submitted.update(data)) + res = request_tool_approval("browser_navigate", "external URL", + rule_key="ext-nav") + assert res["approved"] is False + assert res["status"] == "approval_required" + assert submitted["pattern_key"] == "plugin_rule:ext-nav" + + def test_cron_deny_mode_blocks(self, monkeypatch): + monkeypatch.setattr(approval, "_is_interactive_cli", lambda: False) + monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False) + monkeypatch.setattr(approval, "env_var_enabled", + lambda v: v == "HERMES_CRON_SESSION") + monkeypatch.setattr(approval, "_get_cron_approval_mode", lambda: "deny") + res = request_tool_approval("terminal", "smtp send") + assert res["approved"] is False + assert "cron" in res["message"].lower() + + def test_cron_approve_mode_allows(self, monkeypatch): + monkeypatch.setattr(approval, "_is_interactive_cli", lambda: False) + monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False) + monkeypatch.setattr(approval, "env_var_enabled", + lambda v: v == "HERMES_CRON_SESSION") + monkeypatch.setattr(approval, "_get_cron_approval_mode", lambda: "approve") + res = request_tool_approval("terminal", "smtp send") + assert res["approved"] is True + + def test_rule_key_derived_from_tool_and_reason(self, monkeypatch): + """With no explicit rule_key, the pattern key is derived from + tool + a hash of the reason (so distinct reasons persist apart).""" + monkeypatch.setattr(approval, "_is_interactive_cli", lambda: True) + monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False) + monkeypatch.setattr(approval, "prompt_dangerous_approval", lambda *a, **k: "deny") + res = request_tool_approval("patch", "reason") # no rule_key + assert res["pattern_key"].startswith("plugin_rule:patch:") + + def test_distinct_reasons_get_distinct_keys(self, monkeypatch): + """Two different reasons on the SAME tool must not share an [a]lways + allowlist entry (Finding 3: tool_name alone was too coarse).""" + monkeypatch.setattr(approval, "_is_interactive_cli", lambda: True) + monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False) + monkeypatch.setattr(approval, "prompt_dangerous_approval", lambda *a, **k: "deny") + k1 = request_tool_approval("write_file", "write to ~/.ssh")["pattern_key"] + k2 = request_tool_approval("write_file", "send an email")["pattern_key"] + assert k1 != k2 + + def test_explicit_rule_key_overrides_derivation(self, monkeypatch): + monkeypatch.setattr(approval, "_is_interactive_cli", lambda: True) + monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False) + monkeypatch.setattr(approval, "prompt_dangerous_approval", lambda *a, **k: "deny") + res = request_tool_approval("terminal", "any", rule_key="my-rule") + assert res["pattern_key"] == "plugin_rule:my-rule" + + def test_no_human_non_cron_fails_closed(self, monkeypatch): + """Non-interactive, non-gateway, NON-cron context blocks (fail-closed) + — a plugin-flagged action never runs ungated without a human.""" + monkeypatch.setattr(approval, "_is_interactive_cli", lambda: False) + monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False) + monkeypatch.setattr(approval, "env_var_enabled", lambda v: False) # not cron + res = request_tool_approval("terminal", "smtp send") + assert res["approved"] is False + assert "no interactive user or gateway" in res["message"].lower() + + def test_yolo_session_bypasses_gate(self, monkeypatch): + """A --yolo session skips the plugin approval gate (parity with the + dangerous-command path, via the shared _run_approval_gate).""" + monkeypatch.setattr(approval, "is_current_session_yolo_enabled", lambda: True) + monkeypatch.setattr( + approval, "prompt_dangerous_approval", + lambda *a, **k: pytest.fail("yolo must not prompt"), + ) + res = request_tool_approval("terminal", "curl PUT", rule_key="ext") + assert res == {"approved": True, "message": None} diff --git a/tools/approval.py b/tools/approval.py index c6866850c2ce..d24bbd06b0fa 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -11,6 +11,7 @@ This module is the single source of truth for the dangerous command system: import contextvars import fnmatch import functools +import hashlib import logging import os import re @@ -1995,6 +1996,158 @@ def _smart_approve(command: str, description: str) -> str: return "escalate" +def _run_approval_gate( + *, + pattern_key: str, + description: str, + display_target: str, + approval_callback=None, + cron_deny_message: str, + autoapprove_log_prefix: str, + fail_closed_when_no_human: bool = False, + no_human_block_message: str = "", +) -> dict: + """Shared human-approval gate for a flagged action (command or tool). + + This is the single decision core reused by both + :func:`check_dangerous_command` (dangerous shell patterns) and + :func:`request_tool_approval` (plugin ``pre_tool_call`` ``approve`` + escalations). Extracting it keeps the fail-closed / cron / gateway / + persist policy in ONE place so the two entry points can never drift. + + Ordering mirrors the historical ``check_dangerous_command`` tail: + yolo bypass → session-cache short-circuit → interactive/gateway/cron + branch → prompt → ``deny/session/always`` persistence. The caller is + responsible for the checks that are specific to its input shape + (hardline detection, command-string permanent allowlist, dangerous- + pattern detection) BEFORE calling this gate. + + Args: + pattern_key: Allowlist/session key this decision is stored under. + description: Human-facing reason shown in the prompt. + display_target: The command string or synthetic tool label shown + to the user (redacted by ``prompt_dangerous_approval``). + approval_callback: Optional CLI prompt callback. When ``None`` the + per-thread callback registered via + ``tools.terminal_tool.set_approval_callback`` is used. + cron_deny_message: Message returned when a cron job hits this gate + under ``cron_mode: deny``. + autoapprove_log_prefix: Log line prefix for the non-interactive + auto-approve warning (identifies command vs plugin origin). + fail_closed_when_no_human: When True, a non-interactive non-gateway + context that is NOT a cron session (e.g. a bare script with + HERMES_INTERACTIVE unset) BLOCKS instead of auto-approving. The + dangerous-command path keeps its historical fail-open default + (False); the plugin-escalation path opts in to fail-closed so a + plugin-flagged action never runs ungated without a human. + no_human_block_message: Message returned when + ``fail_closed_when_no_human`` blocks. + + Returns: + ``{"approved": bool, "message": str|None, ...}`` — shape shared with + ``check_dangerous_command`` so all callers handle it uniformly. + """ + # --yolo bypasses all approval prompts (session- or process-scoped). + # Hardline blocks are handled by the caller BEFORE this gate, so yolo + # here only skips the recoverable approval layer. + if _YOLO_MODE_FROZEN or is_current_session_yolo_enabled(): + return {"approved": True, "message": None} + + session_key = get_current_session_key() + if is_approved(session_key, pattern_key): + return {"approved": True, "message": None} + + if approval_callback is None: + try: + from tools.terminal_tool import _get_approval_callback + approval_callback = _get_approval_callback() + except Exception: + approval_callback = None + + is_cli = _is_interactive_cli() + is_gateway = _is_gateway_approval_context() + + if not is_cli and not is_gateway: + # Cron sessions: respect cron_mode config + if env_var_enabled("HERMES_CRON_SESSION"): + if _get_cron_approval_mode() == "deny": + return { + "approved": False, + "message": cron_deny_message, + "pattern_key": pattern_key, + "description": description, + } + # cron_mode: approve — fall through to auto-approve below. + elif fail_closed_when_no_human: + # Non-cron, non-interactive, no gateway: no human can answer. + # The plugin-escalation path opts in to fail-closed here so a + # plugin-flagged action never runs ungated. (The dangerous- + # command path keeps the historical fail-open default.) + logger.warning( + "%s (pattern: %s): %s — no interactive user/gateway present; " + "BLOCKED (fail-closed). Set HERMES_INTERACTIVE or " + "HERMES_GATEWAY_SESSION to answer the prompt.", + autoapprove_log_prefix, pattern_key, description, + ) + return { + "approved": False, + "message": no_human_block_message or ( + f"BLOCKED: approval required ({description}) but no " + "interactive user or gateway is present to approve it." + ), + "pattern_key": pattern_key, + "description": description, + } + logger.warning( + "%s (pattern: %s): %s — set HERMES_INTERACTIVE or " + "HERMES_GATEWAY_SESSION to require approval.", + autoapprove_log_prefix, pattern_key, description, + ) + return {"approved": True, "message": None} + + if is_gateway or env_var_enabled("HERMES_EXEC_ASK"): + submit_pending(session_key, { + "command": display_target, + "pattern_key": pattern_key, + "description": description, + }) + return { + "approved": False, + "pattern_key": pattern_key, + "status": "approval_required", + "command": display_target, + "description": description, + "message": ( + f"⚠️ This action is potentially dangerous ({description}). " + f"Asking the user for approval.\n\n**Target:**\n```\n{display_target}\n```" + ), + } + + choice = prompt_dangerous_approval(display_target, description, + approval_callback=approval_callback) + + if choice == "deny": + return { + "approved": False, + "message": ( + f"BLOCKED: User denied this potentially dangerous action " + f"(matched '{description}'). Do NOT retry — the user has " + "explicitly rejected it." + ), + "pattern_key": pattern_key, + "description": description, + } + + if choice == "session": + approve_session(session_key, pattern_key) + elif choice == "always": + approve_session(session_key, pattern_key) + approve_permanent(pattern_key) + save_permanent_allowlist(_permanent_approved) + + return {"approved": True, "message": None} + + def _should_skip_container_guards(env_type: str, has_host_access: bool = False) -> bool: """Return True when the backend is isolated enough to skip dangerous-command prompts. @@ -2061,71 +2214,109 @@ def check_dangerous_command(command: str, env_type: str, if not is_dangerous: return {"approved": True, "message": None} - session_key = get_current_session_key() - if is_approved(session_key, pattern_key): - return {"approved": True, "message": None} + return _run_approval_gate( + pattern_key=pattern_key, + description=description, + display_target=command, + approval_callback=approval_callback, + cron_deny_message=( + f"BLOCKED: Command flagged as dangerous ({description}) " + "but cron jobs run without a user present to approve it. " + "Find an alternative approach that avoids this command. " + "To allow dangerous commands in cron jobs, set " + "approvals.cron_mode: approve in config.yaml." + ), + autoapprove_log_prefix=( + "AUTO-APPROVED dangerous command in non-interactive non-gateway context" + ), + ) - is_cli = _is_interactive_cli() - is_gateway = _is_gateway_approval_context() - if not is_cli and not is_gateway: - # Cron sessions: respect cron_mode config - if env_var_enabled("HERMES_CRON_SESSION"): - if _get_cron_approval_mode() == "deny": - return { - "approved": False, - "message": ( - f"BLOCKED: Command flagged as dangerous ({description}) " - "but cron jobs run without a user present to approve it. " - "Find an alternative approach that avoids this command. " - "To allow dangerous commands in cron jobs, set " - "approvals.cron_mode: approve in config.yaml." - ), - } - logger.warning( - "AUTO-APPROVED dangerous command in non-interactive non-gateway context " - "(pattern: %s): %s — set HERMES_INTERACTIVE or HERMES_GATEWAY_SESSION to require approval.", - description, command[:200], - ) - return {"approved": True, "message": None} +def request_tool_approval( + tool_name: str, + reason: str, + *, + rule_key: str = "", + approval_callback=None, +) -> dict: + """Escalate an arbitrary tool call to the human-approval gate. - if is_gateway or env_var_enabled("HERMES_EXEC_ASK"): - submit_pending(session_key, { - "command": command, - "pattern_key": pattern_key, - "description": description, - }) - return { - "approved": False, - "pattern_key": pattern_key, - "status": "approval_required", - "command": command, - "description": description, - "message": ( - f"⚠️ This command is potentially dangerous ({description}). " - f"Asking the user for approval.\n\n**Command:**\n```\n{command}\n```" - ), - } + This is the entry point for a plugin ``pre_tool_call`` hook that returns + ``{"action": "approve", "message": ...}``: instead of the plugin vetoing + the call (``action: block``) or silently allowing it, it asks the SAME + human gate that Tier-2 dangerous shell patterns use. The LLM cannot skip + or bypass this — the tool call is intercepted before execution. - choice = prompt_dangerous_approval(command, description, - approval_callback=approval_callback) + It reuses the existing approval primitives (session/permanent allowlist, + ``prompt_dangerous_approval`` for CLI, ``submit_pending`` for the gateway + callback, ``[o]nce/[s]ession/[a]lways/[d]eny``, timeout fail-closed) so + behavior is identical to a dangerous-command match — only the trigger + (a plugin rule on any tool) differs. - if choice == "deny": - return { - "approved": False, - "message": f"BLOCKED: User denied this potentially dangerous command (matched '{description}' pattern). Do NOT retry this command - the user has explicitly rejected it.", - "pattern_key": pattern_key, - "description": description, - } + Args: + tool_name: The tool being gated (e.g. ``"write_file"``, ``"terminal"``). + reason: Human-facing message from the plugin explaining why approval + is needed (rendered in the prompt). + rule_key: Optional stable identifier the plugin can supply to control + the ``[a]lways`` allowlist grain. When empty, the key is derived + from ``tool_name`` + a hash of ``reason`` so that DISTINCT reasons + on the same tool persist independently (answering ``[a]lways`` to + "write to ~/.ssh" does NOT auto-approve a later "send email" rule + on the same tool). + approval_callback: Optional CLI callback for interactive prompts + (same contract as ``check_dangerous_command``). - if choice == "session": - approve_session(session_key, pattern_key) - elif choice == "always": - approve_session(session_key, pattern_key) - approve_permanent(pattern_key) - save_permanent_allowlist(_permanent_approved) + Returns: + ``{"approved": True, "message": None}`` when allowed, or + ``{"approved": False, "message": , ...}`` when denied / + blocked. Shape matches ``check_dangerous_command`` so callers handle + both paths identically. - return {"approved": True, "message": None} + Non-interactive contexts: cron jobs honor ``approvals.cron_mode`` (parity + with dangerous commands); any OTHER non-interactive non-gateway context + (a bare script with no ``HERMES_INTERACTIVE``) fails CLOSED — a plugin- + flagged action never runs ungated without a human. + """ + description = reason or f"Plugin requires approval for {tool_name}" + # Allowlist grain: an explicit plugin rule_key wins; otherwise derive from + # tool + a short hash of the reason so distinct reasons on the same tool + # get independent [a]lways entries (Finding: rule_key=tool_name alone was + # too coarse — one "always" would blanket every rule on that tool). + if rule_key: + key_suffix = rule_key + else: + _reason_hash = hashlib.sha256(description.encode("utf-8")).hexdigest()[:12] + key_suffix = f"{tool_name}:{_reason_hash}" + # Synthetic pattern key so plugin-rule approvals live in the same + # session/permanent allowlist machinery as command patterns, namespaced + # to avoid ever colliding with a real command pattern key. + pattern_key = f"plugin_rule:{key_suffix}" + # A synthetic "command" string for the display/allowlist layer. It never + # executes; it only labels the gate. Namespaced identically. + display_target = f"<{tool_name}> (plugin approval rule)" + + return _run_approval_gate( + pattern_key=pattern_key, + description=description, + display_target=display_target, + approval_callback=approval_callback, + cron_deny_message=( + f"BLOCKED: Tool '{tool_name}' requires approval ({description}) " + "but cron jobs run without a user present to approve it. Find an " + "alternative approach. To allow flagged actions in cron jobs, set " + "approvals.cron_mode: approve in config.yaml." + ), + autoapprove_log_prefix=( + f"plugin-escalated tool call '{tool_name}' in " + "non-interactive non-gateway context" + ), + fail_closed_when_no_human=True, + no_human_block_message=( + f"BLOCKED: Tool '{tool_name}' requires approval ({description}) " + "but no interactive user or gateway is present to approve it. " + "A plugin flagged this action for human confirmation." + ), + ) # ========================================================================= From 117f49b7d46b77685205311ae2149c2e2de6256d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:52:24 -0700 Subject: [PATCH 150/610] fix(approval): wire gateway notify round-trip into the plugin escalation gate _run_approval_gate's gateway branch only queued via submit_pending, so plugin-escalated approvals never sent the interactive embed+buttons on Discord/Telegram/Slack (#59413) - the user was never notified and the action stayed silently blocked. Mirror check_dangerous_command's path: when a session notify callback is registered, run the blocking _await_gateway_decision round-trip (redacted payload, once/session/ always persistence, deny/timeout produce definitive BLOCKED outcomes); fall back to submit_pending only when no callback exists. Fixes #59413. --- tools/approval.py | 66 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tools/approval.py b/tools/approval.py index d24bbd06b0fa..05f2eb523cc3 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -2106,6 +2106,72 @@ def _run_approval_gate( return {"approved": True, "message": None} if is_gateway or env_var_enabled("HERMES_EXEC_ASK"): + # Interactive gateway round-trip when a notify callback is + # registered for this session (Discord/Telegram/Slack embed + + # buttons, same mechanism as check_dangerous_command). Blocks the + # agent thread until the user answers; the agent never sees + # "approval_required" on this path — it gets a definitive + # approved/BLOCKED outcome. + notify_cb = None + with _lock: + notify_cb = _gateway_notify_cbs.get(session_key) + + if notify_cb is not None: + from agent.redact import redact_sensitive_text + approval_data = { + "command": redact_sensitive_text(display_target), + "pattern_key": pattern_key, + "pattern_keys": [pattern_key], + "description": redact_sensitive_text(description), + "allow_permanent": True, + } + decision = _await_gateway_decision( + session_key, notify_cb, approval_data, surface="gateway" + ) + if decision.get("notify_failed"): + return { + "approved": False, + "message": "BLOCKED: Failed to send approval request to user. Do NOT retry.", + "pattern_key": pattern_key, + "description": description, + } + resolved = decision["resolved"] + choice = decision["choice"] + deny_reason = decision.get("reason") + + if not resolved or choice is None or choice == "deny": + if not resolved: + reason = "timed out without user response" + timeout_addendum = " Silence is not consent." + else: + reason = "denied by user" + timeout_addendum = "" + reason_addendum = "" + if resolved and deny_reason: + reason_addendum = f' Reason given by the user: "{deny_reason}".' + return { + "approved": False, + "message": ( + f"BLOCKED: Action {reason}.{reason_addendum} The user " + f"has NOT consented to this action. Do NOT retry it, " + f"do NOT rephrase it, and do NOT attempt the same " + f"outcome via a different path.{timeout_addendum}" + ), + "pattern_key": pattern_key, + "description": description, + "user_consent": False, + } + + if choice == "session": + approve_session(session_key, pattern_key) + elif choice == "always": + approve_session(session_key, pattern_key) + approve_permanent(pattern_key) + save_permanent_allowlist(_permanent_approved) + return {"approved": True, "message": None} + + # No notify callback (e.g. API server without an attached chat): + # queue for /approve /deny review, agent sees approval_required. submit_pending(session_key, { "command": display_target, "pattern_key": pattern_key, From d5a5ea8640106b37705f4ce188553e9231a41409 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:52:41 -0700 Subject: [PATCH 151/610] chore: add doncazper to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 7a9fdc4ac8d3..119ef7e0391f 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -292,6 +292,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "caztronics@yahoo.com": "doncazper", "30668368+alex107ivanov@users.noreply.github.com": "alex107ivanov", "210088133+rungmc357@users.noreply.github.com": "rungmc357", "florian.rutishauser@outlook.com": "flo1t", From 0ecfbc989005cb3eccdbb8351a2510ac7001450d Mon Sep 17 00:00:00 2001 From: Benn Denton <80194363+TinkerOfThings@users.noreply.github.com> Date: Sun, 21 Jun 2026 08:48:21 +0000 Subject: [PATCH 152/610] feat(pty): RingBuffer for keep-alive output capture Co-Authored-By: Claude Opus 4.8 --- hermes_cli/pty_session.py | 32 ++++++++++++++++++++++++++++++++ tests/test_pty_session.py | 24 ++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 hermes_cli/pty_session.py create mode 100644 tests/test_pty_session.py diff --git a/hermes_cli/pty_session.py b/hermes_cli/pty_session.py new file mode 100644 index 000000000000..59cede640a0f --- /dev/null +++ b/hermes_cli/pty_session.py @@ -0,0 +1,32 @@ +"""Keep-alive PTY sessions for dashboard terminals. + +A PTY process outlives the WebSocket that created it: a single drain task +always reads the PTY into a bounded RingBuffer and forwards to the attached +socket when present. Reconnecting with the same opaque token replays the +buffer and resumes live. See +docs/superpowers/specs/2026-06-20-pty-keepalive-reattach-design.md. +""" +from __future__ import annotations + + +class RingBuffer: + """Keeps only the most recent ``capacity`` bytes appended to it.""" + + def __init__(self, capacity: int) -> None: + self._cap = capacity + self._buf = bytearray() + self._truncated = False + + def append(self, data: bytes) -> None: + self._buf.extend(data) + overflow = len(self._buf) - self._cap + if overflow > 0: + del self._buf[:overflow] + self._truncated = True + + def snapshot(self) -> bytes: + return bytes(self._buf) + + @property + def truncated(self) -> bool: + return self._truncated diff --git a/tests/test_pty_session.py b/tests/test_pty_session.py new file mode 100644 index 000000000000..1ab5bacdbaa5 --- /dev/null +++ b/tests/test_pty_session.py @@ -0,0 +1,24 @@ +from hermes_cli.pty_session import RingBuffer + + +def test_ringbuffer_keeps_everything_under_capacity(): + rb = RingBuffer(10) + rb.append(b"abc") + rb.append(b"def") + assert rb.snapshot() == b"abcdef" + assert rb.truncated is False + + +def test_ringbuffer_drops_oldest_over_capacity(): + rb = RingBuffer(4) + rb.append(b"abcdef") # 6 bytes into a 4-byte buffer + assert rb.snapshot() == b"cdef" + assert rb.truncated is True + + +def test_ringbuffer_truncation_across_appends(): + rb = RingBuffer(3) + rb.append(b"ab") + rb.append(b"cd") # now "abcd" -> keep "bcd" + assert rb.snapshot() == b"bcd" + assert rb.truncated is True From e5ac169c2877966689288f067b49c111d5b96380 Mon Sep 17 00:00:00 2001 From: Benn Denton <80194363+TinkerOfThings@users.noreply.github.com> Date: Sun, 21 Jun 2026 08:48:21 +0000 Subject: [PATCH 153/610] feat(pty): PtySession drain/attach/detach with EOF close 4410 Co-Authored-By: Claude Opus 4.8 --- hermes_cli/pty_session.py | 79 +++++++++++++++++++++++++++++++++ tests/test_pty_session.py | 91 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) diff --git a/hermes_cli/pty_session.py b/hermes_cli/pty_session.py index 59cede640a0f..0bc6f1104a83 100644 --- a/hermes_cli/pty_session.py +++ b/hermes_cli/pty_session.py @@ -8,6 +8,13 @@ docs/superpowers/specs/2026-06-20-pty-keepalive-reattach-design.md. """ from __future__ import annotations +import asyncio +import time +from typing import Optional + +WS_CLOSE_PROCESS_EXITED = 4410 +WS_CLOSE_SUPERSEDED = 4409 + class RingBuffer: """Keeps only the most recent ``capacity`` bytes appended to it.""" @@ -30,3 +37,75 @@ class RingBuffer: @property def truncated(self) -> bool: return self._truncated + + +class PtySession: + def __init__(self, key: str, bridge, *, buffer_cap: int, read_timeout: float) -> None: + self.key = key + self.bridge = bridge + self.buffer = RingBuffer(buffer_cap) + self.alive = True + self.attached = False + self.last_detached_at: Optional[float] = None + self._read_timeout = read_timeout + self._ws = None + self._drain_task: Optional[asyncio.Task] = None + + async def start(self) -> None: + self._drain_task = asyncio.create_task(self._drain()) + + async def _drain(self) -> None: + loop = asyncio.get_running_loop() + while True: + chunk = await loop.run_in_executor(None, self.bridge.read, self._read_timeout) + if chunk is None: # EOF — the agent process exited + self.alive = False + ws = self._ws + if ws is not None: + try: + await ws.close(code=WS_CLOSE_PROCESS_EXITED) + except Exception: + pass + return + if not chunk: # idle tick + await asyncio.sleep(0) + continue + self.buffer.append(chunk) + ws = self._ws + if ws is not None: + try: + await ws.send_bytes(chunk) + except Exception: + pass # detached mid-send; keep buffering + + async def attach(self, ws) -> None: + old = self._ws + if old is not None and old is not ws: + try: + await old.close(code=WS_CLOSE_SUPERSEDED) + except Exception: + pass + self._ws = ws + self.attached = True + self.last_detached_at = None + snap = self.buffer.snapshot() + if snap: + await ws.send_bytes(snap) + + def detach(self, ws) -> None: + if self._ws is ws: + self._ws = None + self.attached = False + self.last_detached_at = time.monotonic() + + async def close(self) -> None: + if self._drain_task is not None: + self._drain_task.cancel() + try: + await self._drain_task + except (asyncio.CancelledError, Exception): + pass + try: + self.bridge.close() + except Exception: + pass diff --git a/tests/test_pty_session.py b/tests/test_pty_session.py index 1ab5bacdbaa5..aa477bedb70d 100644 --- a/tests/test_pty_session.py +++ b/tests/test_pty_session.py @@ -1,3 +1,8 @@ +import asyncio +import time + +import pytest + from hermes_cli.pty_session import RingBuffer @@ -22,3 +27,89 @@ def test_ringbuffer_truncation_across_appends(): rb.append(b"cd") # now "abcd" -> keep "bcd" assert rb.snapshot() == b"bcd" assert rb.truncated is True + + +class FakeBridge: + """Implements the bridge contract PtySession depends on.""" + + def __init__(self, chunks): + self._chunks = list(chunks) # bytes; b"" = idle tick; None = EOF + self.written = bytearray() + self.closed = False + self.resized = None + + def read(self, timeout): + if not self._chunks: + return b"" # idle + return self._chunks.pop(0) + + def write(self, data): + self.written.extend(data) + + def resize(self, cols, rows): + self.resized = (cols, rows) + + def close(self): + self.closed = True + + +class FakeWS: + def __init__(self): + self.sent = [] # list of ("bytes"|"text", payload) + self.close_code = None + + async def send_bytes(self, data): + self.sent.append(("bytes", bytes(data))) + + async def send_text(self, text): + self.sent.append(("text", text)) + + async def close(self, code=1000, reason=""): + self.close_code = code + + +@pytest.mark.asyncio +async def test_attach_replays_buffer_then_streams_live(): + from hermes_cli.pty_session import PtySession + bridge = FakeBridge([b"hello ", b"world", None]) + s = PtySession("k", bridge, buffer_cap=1024, read_timeout=0.01) + await s.start() + await asyncio.sleep(0.05) # drain consumes "hello world" + ws = FakeWS() + await s.attach(ws) + replay = b"".join(p for kind, p in ws.sent if kind == "bytes") + assert replay == b"hello world" + await s.close() + + +@pytest.mark.asyncio +async def test_detach_keeps_draining_into_buffer(): + from hermes_cli.pty_session import PtySession + bridge = FakeBridge([b"one", b"", b"two"]) + s = PtySession("k", bridge, buffer_cap=1024, read_timeout=0.01) + await s.start() + ws = FakeWS() + await s.attach(ws) + s.detach(ws) + assert s.attached is False + assert s.last_detached_at is not None + await asyncio.sleep(0.05) # "two" drains while detached + ws2 = FakeWS() + await s.attach(ws2) + replay = b"".join(p for kind, p in ws2.sent if kind == "bytes") + assert replay == b"onetwo" + await s.close() + + +@pytest.mark.asyncio +async def test_eof_marks_dead_and_closes_socket_4410(): + from hermes_cli.pty_session import PtySession + bridge = FakeBridge([b"bye", None]) + s = PtySession("k", bridge, buffer_cap=1024, read_timeout=0.01) + await s.start() + ws = FakeWS() + await s.attach(ws) + await asyncio.sleep(0.05) # drain hits None (EOF) + assert s.alive is False + assert ws.close_code == 4410 + await s.close() From 41166bbe0d51cf9c5ee5ae4b9c773f2f90af9328 Mon Sep 17 00:00:00 2001 From: Benn Denton <80194363+TinkerOfThings@users.noreply.github.com> Date: Sun, 21 Jun 2026 08:48:21 +0000 Subject: [PATCH 154/610] feat(pty): PtySessionRegistry with reap + capacity Co-Authored-By: Claude Opus 4.8 --- hermes_cli/pty_session.py | 64 +++++++++++++++++++++++++++++++++++++++ tests/test_pty_session.py | 47 ++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/hermes_cli/pty_session.py b/hermes_cli/pty_session.py index 0bc6f1104a83..fd7b614d3346 100644 --- a/hermes_cli/pty_session.py +++ b/hermes_cli/pty_session.py @@ -109,3 +109,67 @@ class PtySession: self.bridge.close() except Exception: pass + + +from typing import Callable, Dict, Tuple + + +class RegistryFull(Exception): + pass + + +class PtySessionRegistry: + def __init__(self, *, ttl: float, max_sessions: int, + buffer_cap: int, read_timeout: float) -> None: + self._ttl = ttl + self._max = max_sessions + self._buffer_cap = buffer_cap + self._read_timeout = read_timeout + self._sessions: Dict[str, PtySession] = {} + + async def attach_or_spawn(self, key: str, *, spawn: Callable[[], object] + ) -> Tuple[PtySession, bool]: + await self.reap_idle() + existing = self._sessions.get(key) + if existing is not None and existing.alive: + return existing, False + if existing is not None: # dead remnant + await existing.close() + self._sessions.pop(key, None) + if len(self._sessions) >= self._max: + self._reap_one_idle_or_raise() + bridge = spawn() + session = PtySession(key, bridge, buffer_cap=self._buffer_cap, + read_timeout=self._read_timeout) + await session.start() + self._sessions[key] = session + return session, True + + def detach(self, key: str, ws) -> None: + s = self._sessions.get(key) + if s is not None: + s.detach(ws) + + async def reap_idle(self, now: Optional[float] = None) -> None: + now = time.monotonic() if now is None else now + doomed = [ + key for key, s in self._sessions.items() + if (not s.alive) + or (not s.attached and s.last_detached_at is not None + and (now - s.last_detached_at) > self._ttl) + ] + for key in doomed: + await self._sessions.pop(key).close() + + def _reap_one_idle_or_raise(self) -> None: + idle = [s for s in self._sessions.values() + if not s.attached and s.last_detached_at is not None] + if not idle: + raise RegistryFull() + oldest = min(idle, key=lambda s: s.last_detached_at or 0.0) + self._sessions.pop(oldest.key, None) + asyncio.create_task(oldest.close()) + + async def close_all(self) -> None: + for key in list(self._sessions): + await self._sessions.pop(key).close() diff --git a/tests/test_pty_session.py b/tests/test_pty_session.py index aa477bedb70d..f291bef134c1 100644 --- a/tests/test_pty_session.py +++ b/tests/test_pty_session.py @@ -113,3 +113,50 @@ async def test_eof_marks_dead_and_closes_socket_4410(): assert s.alive is False assert ws.close_code == 4410 await s.close() + + +from hermes_cli.pty_session import PtySessionRegistry, RegistryFull + + +def make_registry(ttl=1800.0, max_sessions=16): + return PtySessionRegistry(ttl=ttl, max_sessions=max_sessions, + buffer_cap=1024, read_timeout=0.01) + + +@pytest.mark.asyncio +async def test_same_key_reattaches_same_session(): + reg = make_registry() + b1 = FakeBridge([b"", b"", b""]) + s1, created1 = await reg.attach_or_spawn("tok", spawn=lambda: b1) + s2, created2 = await reg.attach_or_spawn("tok", spawn=lambda: FakeBridge([])) + assert created1 is True and created2 is False + assert s1 is s2 + assert s2.bridge is b1 # second spawn callable was NOT used + await reg.close_all() + + +@pytest.mark.asyncio +async def test_reap_idle_closes_sessions_past_ttl(): + reg = make_registry(ttl=10.0) + b = FakeBridge([b"", b""]) + s, _ = await reg.attach_or_spawn("tok", spawn=lambda: b) + ws = FakeWS() + await s.attach(ws) + s.detach(ws) + s.last_detached_at = time.monotonic() - 11.0 # detached 11s ago, ttl 10s + await reg.reap_idle() + assert b.closed is True + s2, created = await reg.attach_or_spawn("tok", spawn=lambda: FakeBridge([])) + assert created is True + await reg.close_all() + + +@pytest.mark.asyncio +async def test_new_key_at_capacity_raises_when_none_reapable(): + reg = make_registry(max_sessions=1) + b = FakeBridge([b"", b""]) + s, _ = await reg.attach_or_spawn("a", spawn=lambda: b) + await s.attach(FakeWS()) # attached → not reapable + with pytest.raises(RegistryFull): + await reg.attach_or_spawn("b", spawn=lambda: FakeBridge([])) + await reg.close_all() From e10e4bca825fff6b3c690857ad18c56aabaf1a5f Mon Sep 17 00:00:00 2001 From: Benn Denton <80194363+TinkerOfThings@users.noreply.github.com> Date: Sun, 21 Jun 2026 08:48:21 +0000 Subject: [PATCH 155/610] feat(chat): reattach /api/pty sessions via ?attach= token Keep-alive path when ?attach= is present: PTY outlives the socket via PTY_REGISTRY, reattaches on reconnect. No token = unchanged legacy pump (_legacy_pump). detach (not close) on disconnect. Co-Authored-By: Claude Opus 4.8 --- hermes_cli/pty_session.py | 18 ++- hermes_cli/web_server.py | 198 +++++++++++++++++++++++---------- tests/test_pty_keepalive_ws.py | 53 +++++++++ 3 files changed, 206 insertions(+), 63 deletions(-) create mode 100644 tests/test_pty_keepalive_ws.py diff --git a/hermes_cli/pty_session.py b/hermes_cli/pty_session.py index fd7b614d3346..1a6e9b78bd2a 100644 --- a/hermes_cli/pty_session.py +++ b/hermes_cli/pty_session.py @@ -93,8 +93,14 @@ class PtySession: await ws.send_bytes(snap) def detach(self, ws) -> None: - if self._ws is ws: - self._ws = None + # Only the currently-attached socket may mark the session detached. + # A superseded socket's handler also calls detach on its way out + # (its ``finally`` runs after the new tab attached); flipping + # ``attached`` then would make a session with a live viewer look + # idle and reapable. + if self._ws is not ws: + return + self._ws = None self.attached = False self.last_detached_at = time.monotonic() @@ -106,7 +112,9 @@ class PtySession: except (asyncio.CancelledError, Exception): pass try: - self.bridge.close() + # bridge.close() joins the child — blocking; keep it off the + # event loop (#53227). + await asyncio.to_thread(self.bridge.close) except Exception: pass @@ -138,7 +146,9 @@ class PtySessionRegistry: self._sessions.pop(key, None) if len(self._sessions) >= self._max: self._reap_one_idle_or_raise() - bridge = spawn() + # PTY spawn does blocking fork/exec work — keep it off the event + # loop (#53227). + bridge = await asyncio.to_thread(spawn) session = PtySession(key, bridge, buffer_cap=self._buffer_cap, read_timeout=self._read_timeout) await session.start() diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d8b9d4ac7af8..325d9353de16 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -12540,6 +12540,105 @@ else: _RESIZE_RE = re.compile(rb"\x1b\[RESIZE:(\d+);(\d+)\]") _PTY_READ_CHUNK_TIMEOUT = 0.2 + +# Keep-alive PTY sessions: a terminal connecting with ``?attach=`` is +# bound to a process that survives disconnect/refresh and is reattachable. +from hermes_cli.pty_session import PtySessionRegistry, RegistryFull # noqa: E402 + +PTY_REGISTRY = PtySessionRegistry( + ttl=30 * 60, + max_sessions=16, + buffer_cap=1 * 1024 * 1024, + read_timeout=_PTY_READ_CHUNK_TIMEOUT, +) + + +async def _legacy_pump(ws: "WebSocket", bridge) -> None: + """Original 1:1 socket<->PTY pump: stream until disconnect, then close the + bridge. Used when no ``?attach=`` token is supplied (keep-alive opt-in). + + Behavior is identical to the pre-keep-alive ``pty_ws`` body, including the + #54028 half-open-socket protection (reader EOF → close the WS so the + writer's ``ws.receive()`` unparks) and the #53227 ``to_thread`` offloads + for the blocking ``bridge.close()``. + """ + loop = asyncio.get_running_loop() + + # --- reader task: PTY master → WebSocket ---------------------------- + async def pump_pty_to_ws() -> None: + try: + while True: + chunk = await loop.run_in_executor( + None, bridge.read, _PTY_READ_CHUNK_TIMEOUT + ) + if chunk is None: # EOF + return + if not chunk: # no data this tick; yield control and retry + await asyncio.sleep(0) + continue + try: + await ws.send_bytes(chunk) + except Exception: + return + finally: + # The child has exited (EOF) or the send side broke. Close the + # WebSocket so the writer loop's ``ws.receive()`` returns instead + # of blocking forever — otherwise, when the browser's socket is + # half-open (no FIN delivered, common on macOS/launchd) the + # handler never reaches its ``finally`` and the PTY's fds leak. + # With dashboard auto-reconnect (#52962) every dropped socket then + # stacks a fresh PTY on top of the orphaned one, exhausting fds. + # + # Reap the bridge here too (close() is idempotent): on child EOF the + # writer loop's ``finally`` is the usual closer, but if the handler + # task is cancelled the instant we close the WS, that ``finally`` + # can be skipped, leaking the PTY. Closing from the EOF path makes + # the reap independent of that cancellation race (#54028). + try: + await asyncio.to_thread(bridge.close) + except Exception: + pass + try: + await ws.close() + except Exception: + pass + + reader_task = asyncio.create_task(pump_pty_to_ws()) + + # --- writer loop: WebSocket → PTY master ---------------------------- + try: + while True: + try: + msg = await ws.receive() + except RuntimeError: + # Raised when ws.receive() is called after the socket is + # already disconnected (e.g. closed by the reader task above). + break + if msg.get("type") == "websocket.disconnect": + break + raw = msg.get("bytes") + if raw is None: + text = msg.get("text") + raw = text.encode("utf-8") if isinstance(text, str) else b"" + if not raw: + continue + # Resize escape is consumed locally, never written to the PTY. + match = _RESIZE_RE.match(raw) + if match and match.end() == len(raw): + bridge.resize(cols=int(match.group(1)), rows=int(match.group(2))) + continue + bridge.write(raw) + except WebSocketDisconnect: + pass + finally: + reader_task.cancel() + try: + await reader_task + except (asyncio.CancelledError, Exception): + pass + await asyncio.to_thread(bridge.close) + + _VALID_CHANNEL_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$") # Starlette's TestClient reports the peer as "testclient"; treat it as # loopback so tests don't need to rewrite request scope. @@ -13745,71 +13844,57 @@ async def pty_ws(ws: WebSocket) -> None: return + attach_token = ws.query_params.get("attach") or None + + def _spawn(): + return PtyBridge.spawn(argv, cwd=cwd, env=env) + + if attach_token is None: + # Legacy path: 1:1 socket<->PTY, killed on disconnect (unchanged). + try: + bridge = _spawn() + except PtyUnavailableError as exc: + await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") + await ws.close(code=1011) + return + except (FileNotFoundError, OSError) as exc: + await ws.send_text(f"\r\n\x1b[31mChat failed to start: {exc}\x1b[0m\r\n") + await ws.close(code=1011) + return + await _legacy_pump(ws, bridge) + return + + # Keep-alive path: the PTY outlives this socket; reattach by token. try: - bridge = await asyncio.to_thread(PtyBridge.spawn, argv, cwd=cwd, env=env) + session, _created = await PTY_REGISTRY.attach_or_spawn( + attach_token, spawn=_spawn + ) except PtyUnavailableError as exc: await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") await ws.close(code=1011) return - except (FileNotFoundError, OSError) as exc: - await ws.send_text(f"\r\n\x1b[31mChat failed to start: {exc}\x1b[0m\r\n") + except (FileNotFoundError, OSError, RegistryFull) as exc: + await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") await ws.close(code=1011) return - loop = asyncio.get_running_loop() - - # --- reader task: PTY master → WebSocket ---------------------------- - async def pump_pty_to_ws() -> None: - try: - while True: - chunk = await loop.run_in_executor( - None, bridge.read, _PTY_READ_CHUNK_TIMEOUT - ) - if chunk is None: # EOF - return - if not chunk: # no data this tick; yield control and retry - await asyncio.sleep(0) - continue - try: - await ws.send_bytes(chunk) - except Exception: - return - finally: - # The child has exited (EOF) or the send side broke. Close the - # WebSocket so the writer loop's ``ws.receive()`` returns instead - # of blocking forever — otherwise, when the browser's socket is - # half-open (no FIN delivered, common on macOS/launchd) the - # handler never reaches its ``finally`` and the PTY's fds leak. - # With dashboard auto-reconnect (#52962) every dropped socket then - # stacks a fresh PTY on top of the orphaned one, exhausting fds. - # - # Reap the bridge here too (close() is idempotent): on child EOF the - # writer loop's ``finally`` is the usual closer, but if the handler - # task is cancelled the instant we close the WS, that ``finally`` - # can be skipped, leaking the PTY. Closing from the EOF path makes - # the reap independent of that cancellation race (#54028). - try: - await asyncio.to_thread(bridge.close) - except Exception: - pass - try: - await ws.close() - except Exception: - pass - - reader_task = asyncio.create_task(pump_pty_to_ws()) + await session.attach(ws) # --- writer loop: WebSocket → PTY master ---------------------------- + # No reader task here: the session's drain task (spawned once per PTY, + # inside the registry) forwards PTY output to whichever socket is + # attached and rings-buffers it while detached. On child EOF the drain + # closes the attached socket with 4410, which unparks ``ws.receive()`` + # below — same half-open-socket protection the legacy pump has (#54028). try: while True: try: msg = await ws.receive() except RuntimeError: - # Raised when ws.receive() is called after the socket is - # already disconnected (e.g. closed by the reader task above). + # ws.receive() after the socket is already disconnected + # (e.g. closed by the drain task on process exit). break - msg_type = msg.get("type") - if msg_type == "websocket.disconnect": + if msg.get("type") == "websocket.disconnect": break raw = msg.get("bytes") if raw is None: @@ -13821,21 +13906,16 @@ async def pty_ws(ws: WebSocket) -> None: # Resize escape is consumed locally, never written to the PTY. match = _RESIZE_RE.match(raw) if match and match.end() == len(raw): - cols = int(match.group(1)) - rows = int(match.group(2)) - bridge.resize(cols=cols, rows=rows) + session.bridge.resize(cols=int(match.group(1)), rows=int(match.group(2))) continue - bridge.write(raw) + session.bridge.write(raw) except WebSocketDisconnect: pass finally: - reader_task.cancel() - try: - await reader_task - except (asyncio.CancelledError, Exception): - pass - await asyncio.to_thread(bridge.close) + # Detach only — the PTY keeps running for a reattach; the registry + # reaper closes it after the TTL (or immediately on process exit). + PTY_REGISTRY.detach(attach_token, ws) # --------------------------------------------------------------------------- diff --git a/tests/test_pty_keepalive_ws.py b/tests/test_pty_keepalive_ws.py new file mode 100644 index 000000000000..782967ef2070 --- /dev/null +++ b/tests/test_pty_keepalive_ws.py @@ -0,0 +1,53 @@ +import pytest + +from hermes_cli import web_server + + +@pytest.mark.asyncio +async def test_attach_token_reuses_same_session(monkeypatch): + """Two connects with the same ?attach= token hit one spawned bridge.""" + spawned = [] + + class FakeBridge: + def __init__(self): + self.alive = True + + def read(self, timeout): + return b"" # idle forever + + def write(self, data): + pass + + def resize(self, cols, rows): + pass + + def close(self): + self.alive = False + + def fake_spawn(argv, cwd=None, env=None): + b = FakeBridge() + spawned.append(b) + return b + + monkeypatch.setattr(web_server.PtyBridge, "spawn", staticmethod(fake_spawn)) + # bypass auth + argv resolution for the test + monkeypatch.setattr(web_server, "_ws_auth_reason", lambda ws: (None, "test")) + monkeypatch.setattr(web_server, "_ws_host_origin_reason", lambda ws: None) + monkeypatch.setattr(web_server, "_ws_client_reason", lambda ws: None) + + async def fake_argv(**kw): + return (["x"], "/tmp", {}) + + monkeypatch.setattr(web_server, "_resolve_chat_argv_async", fake_argv) + + from starlette.testclient import TestClient + + try: + client = TestClient(web_server.app) + with client.websocket_connect("/api/pty?attach=TOK1") as ws1: + ws1.send_bytes(b"hi") + with client.websocket_connect("/api/pty?attach=TOK1") as ws2: + ws2.send_bytes(b"again") + assert len(spawned) == 1 # reattached, did not respawn + finally: + web_server.PTY_REGISTRY._sessions.clear() From c3d2be073a10ee2ed44cf8202361120d0531371e Mon Sep 17 00:00:00 2001 From: Benn Denton <80194363+TinkerOfThings@users.noreply.github.com> Date: Sun, 21 Jun 2026 08:48:21 +0000 Subject: [PATCH 156/610] feat(pty): periodic reaper wired into dashboard lifespan Co-Authored-By: Claude Opus 4.8 --- hermes_cli/pty_session.py | 10 ++++++++++ hermes_cli/web_server.py | 7 ++++++- tests/test_pty_session.py | 20 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/hermes_cli/pty_session.py b/hermes_cli/pty_session.py index 1a6e9b78bd2a..43910be9deb4 100644 --- a/hermes_cli/pty_session.py +++ b/hermes_cli/pty_session.py @@ -126,6 +126,16 @@ class RegistryFull(Exception): pass +async def run_reaper(registry: "PtySessionRegistry", *, interval: float = 60.0) -> None: + """Periodically reap idle/dead keep-alive sessions. Cancelled on shutdown.""" + while True: + await asyncio.sleep(interval) + try: + await registry.reap_idle() + except Exception: + pass + + class PtySessionRegistry: def __init__(self, *, ttl: float, max_sessions: int, buffer_cap: int, read_timeout: float) -> None: diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 325d9353de16..07a5ef619b44 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -204,9 +204,14 @@ async def _lifespan(app: "FastAPI"): ) cron_thread.start() + # Reap idle/dead keep-alive PTY sessions in the background (30-min TTL). + pty_reaper_task = asyncio.create_task(run_reaper(PTY_REGISTRY)) + try: yield finally: + pty_reaper_task.cancel() + await PTY_REGISTRY.close_all() if cron_stop is not None: cron_stop.set() @@ -12543,7 +12548,7 @@ _PTY_READ_CHUNK_TIMEOUT = 0.2 # Keep-alive PTY sessions: a terminal connecting with ``?attach=`` is # bound to a process that survives disconnect/refresh and is reattachable. -from hermes_cli.pty_session import PtySessionRegistry, RegistryFull # noqa: E402 +from hermes_cli.pty_session import PtySessionRegistry, RegistryFull, run_reaper # noqa: E402 PTY_REGISTRY = PtySessionRegistry( ttl=30 * 60, diff --git a/tests/test_pty_session.py b/tests/test_pty_session.py index f291bef134c1..4fcce10c1a81 100644 --- a/tests/test_pty_session.py +++ b/tests/test_pty_session.py @@ -160,3 +160,23 @@ async def test_new_key_at_capacity_raises_when_none_reapable(): with pytest.raises(RegistryFull): await reg.attach_or_spawn("b", spawn=lambda: FakeBridge([])) await reg.close_all() + + +@pytest.mark.asyncio +async def test_reaper_loop_invokes_reap(monkeypatch): + from hermes_cli.pty_session import run_reaper + reg = make_registry() + calls = {"n": 0} + + async def fake_reap(now=None): + calls["n"] += 1 + + monkeypatch.setattr(reg, "reap_idle", fake_reap) + task = asyncio.create_task(run_reaper(reg, interval=0.01)) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + assert calls["n"] >= 2 From 79f4f78fa44369aa9a45eddcbf9534857f029ee0 Mon Sep 17 00:00:00 2001 From: Benn Denton <80194363+TinkerOfThings@users.noreply.github.com> Date: Sun, 21 Jun 2026 08:48:21 +0000 Subject: [PATCH 157/610] feat(chat): persist attach token, reconnect on transient close ChatPage sends ?attach= so /chat reattaches to its live PTY across refresh. onclose: 4410=process-exit (session ended), 4409=superseded (quiet), else transient -> auto-reconnect. Co-Authored-By: Claude Opus 4.8 --- web/src/pages/ChatPage.tsx | 55 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index bb623cf2c6c1..9ca904a96041 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -40,6 +40,34 @@ import { PluginSlot } from "@/plugins"; import { useTheme } from "@/themes"; import { useProfileScope } from "@/contexts/useProfileScope"; +// Stable per-browser token identifying THIS chat tab's keep-alive PTY session. +// Sent as ?attach=; lets a refresh/disconnect reattach to the same live process +// instead of spawning a fresh one. Per-localStorage, so other devices can't grab it. +// ``rotate`` mints a new token — used when the user explicitly starts a fresh +// session so the old keep-alive PTY is NOT reattached (the registry reaps it). +const PTY_ATTACH_TOKEN_KEY = "hermes.pty.token.chat"; +function ptyAttachToken(rotate = false): string { + let t = ""; + if (!rotate) { + try { + t = window.localStorage.getItem(PTY_ATTACH_TOKEN_KEY) ?? ""; + } catch { + /* private mode / storage blocked */ + } + } + if (!t) { + const a = new Uint8Array(16); + crypto.getRandomValues(a); + t = Array.from(a, (b) => b.toString(16).padStart(2, "0")).join(""); + try { + window.localStorage.setItem(PTY_ATTACH_TOKEN_KEY, t); + } catch { + /* ignore */ + } + } + return t; +} + // Channel id ties this chat tab's PTY child (publisher) to its sidebar // (subscriber). Generated once per mount so a tab refresh starts a fresh // channel — the previous PTY child terminates with the old WS, and its @@ -679,6 +707,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const params: Record = { channel }; if (resumeParam) params.resume = resumeParam; if (forceFresh) params.fresh = "1"; + // Keep-alive identity: reattach to this tab's living PTY across + // refresh/transient drops. A forced-fresh start rotates the token so + // the previous keep-alive PTY is not reattached (registry reaps it). + params.attach = ptyAttachToken(forceFresh); // Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the // selected profile, so the conversation runs with that profile's model, // skills, memory, and sessions (see web_server._resolve_chat_argv). @@ -693,6 +725,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { reconnectAttemptRef.current = 0; setBanner(null); setSessionEnded(false); + // Connected — cancel any pending reconnect from a prior transient drop. + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } // Send the initial RESIZE immediately so Ink has *a* size to lay // out against on its first paint. The double-rAF block above will // follow up with the authoritative measurement — at worst Ink @@ -775,7 +812,21 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // Server already wrote an ANSI error frame. return; } + // Keep-alive close-code contract (web_server.pty_ws + pty_session): + // 4410 = the agent PROCESS exited (real end) → restart affordance. + // 4409 = superseded by a newer tab attaching the same token → stay quiet. + if (ev.code === 4410) { + term.write(`\r\n\x1b[90m[session ended]\x1b[0m\r\n`); + setSessionEnded(true); + return; + } + if (ev.code === 4409) { + return; + } if (!ev.wasClean || ev.code === 1001 || ev.code === 1006) { + // Transient transport drop (refresh, sleep/wake, signal loss). + // Reconnect with backoff; the same ?attach= token reattaches to + // the still-living PTY, so the conversation continues in place. scheduleReconnect(ev.code); return; } @@ -854,6 +905,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { clearTimeout(copyResetRef.current); copyResetRef.current = null; } + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } }; }, [channel, clearReconnectTimer, resumeParam, scopedProfile, reconnectNonce]); From 086596ca2b988ca42fd57a5a674b7a00d37998be Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Fri, 3 Jul 2026 05:27:31 +0800 Subject: [PATCH 158/610] fix(mcp): reap orphaned subprocesses before spawning new ones on retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an MCP stdio subprocess fails to connect (token expiry, port contention, timeout), the run() reconnect loop retries with backoff. Each retry calls _run_stdio() which spawns a new process pair, but the previous failed pair was only detected as orphaned (added to _orphan_stdio_pids) — never actually killed. This caused rapid zombie accumulation: 5 failed attempts × 2 procs each = 10 orphans competing for the same port. Add a _kill_orphaned_mcp_children() call at the top of _run_stdio(), before the _snapshot_child_pids() baseline, so any orphans from prior failed attempts are reaped before a new subprocess is spawned. Fixes #57355 --- tests/tools/test_mcp_stability.py | 62 +++++++++++++++++++++++++++++++ tools/mcp_tool.py | 7 ++++ 2 files changed, 69 insertions(+) diff --git a/tests/tools/test_mcp_stability.py b/tests/tools/test_mcp_stability.py index 796dbed913d2..c266dc98bc2c 100644 --- a/tests/tools/test_mcp_stability.py +++ b/tests/tools/test_mcp_stability.py @@ -199,6 +199,68 @@ class TestStdioPidTracking: with _lock: assert fake_pid not in _orphan_stdio_pids + def test_run_stdio_reaps_orphans_before_spawn(self): + """_run_stdio kills orphaned PIDs from prior failed attempts (#57355).""" + from tools.mcp_tool import ( + _kill_orphaned_mcp_children, + _orphan_stdio_pids, + _stdio_pids, + _stdio_pgids, + _lock, + MCPServerTask, + ) + from unittest.mock import patch, MagicMock, AsyncMock + + # Seed an orphan PID that belongs to a prior failed connection. + fake_pid = 999999997 + with _lock: + _orphan_stdio_pids.add(fake_pid) + + server = MCPServerTask.__new__(MCPServerTask) + server.name = "test-zombie-reap" + server._ready = MagicMock() + server._shutdown_event = MagicMock() + server._shutdown_event.is_set.return_value = True + server._reconnect_event = MagicMock() + server._sampling = None + server._elicitation = None + server._registered_tool_names = [] + + config = {"command": "echo", "args": ["hello"]} + + import asyncio + + async def _run(): + # _run_stdio should reap orphans before it gets to the + # stdio_client spawn. Patch the OSV check (local import) + # and stdio_client so no real subprocess is spawned. + with patch("tools.mcp_tool._MCP_AVAILABLE", True), \ + patch("tools.mcp_tool._build_safe_env", return_value={}), \ + patch("tools.mcp_tool._resolve_stdio_command", + return_value=("echo", {})), \ + patch("tools.mcp_tool._write_stderr_log_header"), \ + patch("tools.mcp_tool._get_mcp_stderr_log", + return_value=None), \ + patch("tools.mcp_tool.check_package_for_malware", + return_value=None, create=True), \ + patch("tools.osv_check.check_package_for_malware", + return_value=None): + # Patch stdio_client to raise so the test exits quickly + cm = MagicMock() + cm.__aenter__ = AsyncMock(side_effect=RuntimeError("test")) + cm.__aexit__ = AsyncMock(return_value=False) + with patch("tools.mcp_tool.stdio_client", return_value=cm): + try: + await server._run_stdio(config) + except Exception: + pass + + asyncio.run(_run()) + + # The orphan must have been reaped before the spawn attempt. + with _lock: + assert fake_pid not in _orphan_stdio_pids + # --------------------------------------------------------------------------- # Fix 2b: stdio descendant reaping via process group (issue #23799) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 329ddebdd73f..a57bdf98affa 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1951,6 +1951,13 @@ class MCPServerTask: if _MCP_LOGGING_CALLBACK_SUPPORTED: sampling_kwargs["logging_callback"] = self._make_logging_callback() + # Reap any orphaned subprocesses from a prior failed connection + # attempt before spawning a new one. Without this, each retry in + # the run() reconnect loop spawns a fresh process pair while the + # previous failed pair lingers — leading to rapid zombie + # accumulation (see #57355). + _kill_orphaned_mcp_children() + # Snapshot child PIDs before spawning so we can track the new one. pids_before = _snapshot_child_pids() new_pids: set = set() From f99e9f0d271e7691aa9a34ce13c8e5fa09c1003d Mon Sep 17 00:00:00 2001 From: yoma Date: Sat, 4 Jul 2026 19:01:58 +0800 Subject: [PATCH 159/610] fix(mcp): reap stdio orphans before reconnect --- tests/tools/test_mcp_stability.py | 53 ++++++++++++++++++++++++++++++- tools/mcp_tool.py | 38 +++++++++++++++++++--- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/tests/tools/test_mcp_stability.py b/tests/tools/test_mcp_stability.py index c266dc98bc2c..b9a1b92c9cde 100644 --- a/tests/tools/test_mcp_stability.py +++ b/tests/tools/test_mcp_stability.py @@ -109,6 +109,7 @@ class TestStdioPidTracking: """_kill_orphaned_mcp_children does nothing when no PIDs tracked.""" from tools.mcp_tool import ( _kill_orphaned_mcp_children, + _orphan_stdio_pid_servers, _orphan_stdio_pids, _stdio_pids, _lock, @@ -117,6 +118,7 @@ class TestStdioPidTracking: with _lock: _stdio_pids.clear() _orphan_stdio_pids.clear() + _orphan_stdio_pid_servers.clear() # Should not raise _kill_orphaned_mcp_children() @@ -125,6 +127,7 @@ class TestStdioPidTracking: """_kill_orphaned_mcp_children gracefully handles already-dead PIDs.""" from tools.mcp_tool import ( _kill_orphaned_mcp_children, + _orphan_stdio_pid_servers, _orphan_stdio_pids, _lock, ) @@ -133,6 +136,7 @@ class TestStdioPidTracking: fake_pid = 999999999 with _lock: _orphan_stdio_pids.add(fake_pid) + _orphan_stdio_pid_servers[fake_pid] = "orphan" # Should not raise (ProcessLookupError is caught) _kill_orphaned_mcp_children() @@ -144,6 +148,7 @@ class TestStdioPidTracking: """SIGTERM-first then SIGKILL after 2s for orphan cleanup.""" from tools.mcp_tool import ( _kill_orphaned_mcp_children, + _orphan_stdio_pid_servers, _orphan_stdio_pids, _lock, ) @@ -151,7 +156,9 @@ class TestStdioPidTracking: fake_pid = 424242 with _lock: _orphan_stdio_pids.clear() + _orphan_stdio_pid_servers.clear() _orphan_stdio_pids.add(fake_pid) + _orphan_stdio_pid_servers[fake_pid] = "orphan" fake_sigkill = 9 monkeypatch.setattr(signal, "SIGKILL", fake_sigkill, raising=False) @@ -177,6 +184,7 @@ class TestStdioPidTracking: """Without SIGKILL, SIGTERM is used for both phases.""" from tools.mcp_tool import ( _kill_orphaned_mcp_children, + _orphan_stdio_pid_servers, _orphan_stdio_pids, _lock, ) @@ -184,7 +192,9 @@ class TestStdioPidTracking: fake_pid = 434343 with _lock: _orphan_stdio_pids.clear() + _orphan_stdio_pid_servers.clear() _orphan_stdio_pids.add(fake_pid) + _orphan_stdio_pid_servers[fake_pid] = "orphan" monkeypatch.delattr(signal, "SIGKILL", raising=False) @@ -261,6 +271,37 @@ class TestStdioPidTracking: with _lock: assert fake_pid not in _orphan_stdio_pids + def test_kill_orphaned_can_filter_by_server_name(self): + """Reconnect cleanup reaps only the orphan owned by that MCP server.""" + from tools.mcp_tool import ( + _kill_orphaned_mcp_children, + _orphan_stdio_pid_servers, + _orphan_stdio_pids, + _lock, + ) + + target_pid = 454545 + other_pid = 464646 + with _lock: + _orphan_stdio_pids.clear() + _orphan_stdio_pid_servers.clear() + _orphan_stdio_pids.update({target_pid, other_pid}) + _orphan_stdio_pid_servers[target_pid] = "feishu" + _orphan_stdio_pid_servers[other_pid] = "mimir" + + with patch("tools.mcp_tool.os.kill") as mock_kill, \ + patch("gateway.status._pid_exists", return_value=False), \ + patch("tools.mcp_tool.time.sleep") as mock_sleep: + _kill_orphaned_mcp_children(server_name="feishu") + + mock_kill.assert_called_once_with(target_pid, signal.SIGTERM) + mock_sleep.assert_called_once_with(2) + with _lock: + assert target_pid not in _orphan_stdio_pids + assert target_pid not in _orphan_stdio_pid_servers + assert other_pid in _orphan_stdio_pids + assert _orphan_stdio_pid_servers[other_pid] == "mimir" + # --------------------------------------------------------------------------- # Fix 2b: stdio descendant reaping via process group (issue #23799) @@ -276,10 +317,17 @@ class TestStdioPgroupReaping: """_kill_orphaned_mcp_children reaps via killpg when a pgid is tracked.""" def _reset_state(self): - from tools.mcp_tool import _stdio_pids, _orphan_stdio_pids, _stdio_pgids, _lock + from tools.mcp_tool import ( + _orphan_stdio_pid_servers, + _orphan_stdio_pids, + _stdio_pgids, + _stdio_pids, + _lock, + ) with _lock: _stdio_pids.clear() _orphan_stdio_pids.clear() + _orphan_stdio_pid_servers.clear() _stdio_pgids.clear() def test_killpg_used_when_pgid_tracked(self, monkeypatch): @@ -505,6 +553,7 @@ class TestStdioPgroupReaping: # Drive the reaper: register the parent pid + pgid as an orphan. from tools.mcp_tool import ( _kill_orphaned_mcp_children, + _orphan_stdio_pid_servers, _orphan_stdio_pids, _stdio_pgids, _stdio_pids, @@ -513,8 +562,10 @@ class TestStdioPgroupReaping: with _lock: _stdio_pids.clear() _orphan_stdio_pids.clear() + _orphan_stdio_pid_servers.clear() _stdio_pgids.clear() _orphan_stdio_pids.add(parent.pid) + _orphan_stdio_pid_servers[parent.pid] = "orphan" _stdio_pgids[parent.pid] = parent_pgid try: _kill_orphaned_mcp_children() diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index a57bdf98affa..0877807915c0 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1958,6 +1958,12 @@ class MCPServerTask: # accumulation (see #57355). _kill_orphaned_mcp_children() + # A previous stdio transport for this same server may have survived + # SDK context teardown and been marked orphaned in the prior + # _run_stdio() finally block. Reap it before spawning the replacement + # so reconnect churn cannot accumulate one child per attempt. + _kill_orphaned_mcp_children(server_name=self.name) + # Snapshot child PIDs before spawning so we can track the new one. pids_before = _snapshot_child_pids() new_pids: set = set() @@ -2048,6 +2054,7 @@ class MCPServerTask: pgroup_alive = False if pid_alive or pgroup_alive: _orphan_stdio_pids.add(pid) + _orphan_stdio_pid_servers[pid] = self.name else: # Nothing left to reap — drop the pgid entry so # PID-reuse can't surface stale pgroup state later. @@ -3241,6 +3248,7 @@ _stdio_pids: Dict[int, str] = {} # pid -> server_name # Separate from _stdio_pids so cleanup sweeps never race with active # sessions (e.g. concurrent cron jobs or live user chats). _orphan_stdio_pids: set = set() +_orphan_stdio_pid_servers: Dict[int, str] = {} # Process-group IDs of stdio MCP subprocesses, captured at spawn time. # The MCP SDK spawns stdio children with ``start_new_session=True`` so each @@ -5168,7 +5176,10 @@ def shutdown_mcp_servers(): _stop_mcp_loop() -def _kill_orphaned_mcp_children(include_active: bool = False) -> None: +def _kill_orphaned_mcp_children( + include_active: bool = False, + server_name: Optional[str] = None, +) -> None: """Best-effort graceful shutdown of stdio MCP subprocesses to reap orphans. Orphans are PIDs that survived their session context exit (SDK teardown @@ -5187,6 +5198,10 @@ def _kill_orphaned_mcp_children(include_active: bool = False) -> None: first) are reaped alongside the direct child. Falls back to ``os.kill`` on Windows and when no pgid is recorded. + When ``server_name`` is set, only orphaned PIDs known to belong to that + MCP server are reaped. This lets stdio reconnects clean up their previous + transport without touching unrelated servers. + With ``include_active=True`` also kills every PID in ``_stdio_pids`` — used only at final shutdown, after the MCP event loop has stopped and no sessions can still be in flight. @@ -5196,11 +5211,24 @@ def _kill_orphaned_mcp_children(include_active: bool = False) -> None: with _lock: pids: Dict[int, str] = {} for opid in _orphan_stdio_pids: - pids[opid] = "orphan" - _orphan_stdio_pids.clear() + owner = _orphan_stdio_pid_servers.get(opid, "orphan") + if server_name is not None and owner != server_name: + continue + pids[opid] = owner + for opid in pids: + _orphan_stdio_pids.discard(opid) + _orphan_stdio_pid_servers.pop(opid, None) if include_active: - pids.update(dict(_stdio_pids)) - _stdio_pids.clear() + active = dict(_stdio_pids) + if server_name is not None: + active = { + pid: owner + for pid, owner in active.items() + if owner == server_name + } + pids.update(active) + for pid in active: + _stdio_pids.pop(pid, None) # Snapshot pgids for the pids we're about to kill, then drop the # entries so a future spawn can't collide with stale state. pgids: Dict[int, int] = {pid: _stdio_pgids[pid] for pid in pids if pid in _stdio_pgids} From 743c116fb2486d26e2b26a356b0ffe6838b206a2 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:14:25 -0700 Subject: [PATCH 160/610] fix(mcp): unify reconnect orphan reaping + move off the event loop Merge the two cherry-picked reap call sites into one unscoped sweep at the top of _run_stdio (the unscoped sweep is a superset of the per-server one), and run it via asyncio.to_thread so the 2s SIGTERM->SIGKILL escalation cannot stall the shared MCP event loop. --- tools/mcp_tool.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 0877807915c0..1861e2b352a6 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1951,18 +1951,17 @@ class MCPServerTask: if _MCP_LOGGING_CALLBACK_SUPPORTED: sampling_kwargs["logging_callback"] = self._make_logging_callback() - # Reap any orphaned subprocesses from a prior failed connection - # attempt before spawning a new one. Without this, each retry in + # Reap any orphaned subprocesses from prior failed connection + # attempts before spawning a new one. Without this, each retry in # the run() reconnect loop spawns a fresh process pair while the # previous failed pair lingers — leading to rapid zombie - # accumulation (see #57355). - _kill_orphaned_mcp_children() - - # A previous stdio transport for this same server may have survived - # SDK context teardown and been marked orphaned in the prior - # _run_stdio() finally block. Reap it before spawning the replacement - # so reconnect churn cannot accumulate one child per attempt. - _kill_orphaned_mcp_children(server_name=self.name) + # accumulation (see #57355, #57228). The unscoped sweep also + # opportunistically reaps orphans left by *other* servers that + # never reconnect; per-server filtering via ``server_name`` remains + # available for scoped call sites. Run in a worker thread: the + # reaper blocks up to 2s (SIGTERM → wait → SIGKILL) when orphans + # exist, which would otherwise stall the shared MCP event loop. + await asyncio.to_thread(_kill_orphaned_mcp_children) # Snapshot child PIDs before spawning so we can track the new one. pids_before = _snapshot_child_pids() From 1f6836cd81aeadeae4c19d184c8925bf363ef497 Mon Sep 17 00:00:00 2001 From: rainbowgits <164521089+rainbowgits@users.noreply.github.com> Date: Mon, 6 Jul 2026 08:37:58 +0300 Subject: [PATCH 161/610] fix(mcp): bound stdio initialize handshake to stop subprocess/FD leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stdio MCP server that never completes `initialize` (e.g. emits a non-JSON-RPC frame and then blocks on stdin) leaks a child process plus its stdio pipes/pidfd on every discovery-retry cycle — unbounded, until the gateway hits EMFILE and every new open()/spawn fails (#59349). Root cause (confirmed by instrumenting the live repro, and different from the issue's own hypothesis): the spawned child IS captured in `new_pids`, so the report's "new_pids empty at finally" guess is not it. The real cause is that `session.initialize()` hangs forever on the garbage stream. `connect_timeout` only bounds the caller's `.result()` wait on the foreground thread — it does NOT cancel the `_run_stdio` coroutine on the background MCP loop. So the coroutine is stuck at `await session.initialize()` permanently, its cleanup `finally` never runs, the child is never reaped, and it stays invisible to the orphan-reaper (whose `_orphan_stdio_pids` set never gets populated). Fix: wrap `session.initialize()` in `asyncio.wait_for(..., connect_timeout)` so a stalled handshake fails instead of hanging. The TimeoutError unwinds through the SDK context managers (closing the child's stdin -> EOF -> exit) and lets the existing `finally` reap any straggler. Cross-platform — no signals/pgid/proc. Scope: stdio only. The HTTP path has the same `await session.initialize()` shape but spawns no subprocess (so it can't cause this leak) and already has httpx transport timeouts. Verified: the reporter's repro goes from unbounded growth to draining to zero; added a hermetic regression test (fake transport whose `initialize()` hangs, asserts the connect is bounded by connect_timeout) that fails on the pre-fix code and passes on the fix; 566 existing MCP tests pass; ruff clean. Repro confirmed on macOS (pipe FDs); the Linux-specific pidfd growth in the report should be equivalent — the reporter offered to validate on Linux. Closes #59349 --- tests/tools/test_mcp_stdio_init_timeout.py | 94 ++++++++++++++++++++++ tools/mcp_tool.py | 18 ++++- 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/tools/test_mcp_stdio_init_timeout.py diff --git a/tests/tools/test_mcp_stdio_init_timeout.py b/tests/tools/test_mcp_stdio_init_timeout.py new file mode 100644 index 000000000000..d8d379b10c5f --- /dev/null +++ b/tests/tools/test_mcp_stdio_init_timeout.py @@ -0,0 +1,94 @@ +"""Regression test for the stdio-MCP subprocess/FD leak (#59349). + +A stdio MCP server that never completes ``initialize`` (e.g. emits a +non-JSON-RPC frame and then blocks on stdin) used to hang ``_run_stdio`` +forever on the background event loop: ``connect_timeout`` bounded only the +*caller's* ``.result()`` wait, not the coroutine itself. Because the connect +never unwound, the cleanup ``finally`` in ``_run_stdio`` never ran, so the +spawned child process and its stdio pipes / pidfd leaked on *every* discovery +retry — unbounded, until the gateway hit EMFILE. + +The fix wraps ``session.initialize()`` in +``asyncio.wait_for(..., timeout=connect_timeout)`` so a stalled handshake fails +instead of hanging, which lets the existing ``finally`` reap the child. + +This test drives the *real* ``_run_stdio`` with a fake transport whose +``initialize()`` hangs, and asserts the connect is bounded by +``connect_timeout`` rather than blocking forever. It is fully hermetic — no real +subprocess, no network (the drain-to-zero behaviour was additionally verified +manually against the reporter's live repro). +""" + +from __future__ import annotations + +import asyncio +import time +from unittest.mock import patch + +import pytest + +pytest.importorskip("mcp") + + +class _HangingSession: + """Stand-in ClientSession whose handshake never completes.""" + + async def initialize(self): + await asyncio.sleep(3600) + + +class _FakeAsyncCM: + """Minimal async context manager yielding a fixed value; spawns nothing.""" + + def __init__(self, value): + self._value = value + + async def __aenter__(self): + return self._value + + async def __aexit__(self, *_exc): + return False + + +def _fake_stdio_client(*_args, **_kwargs): + # `async with stdio_client(...) as (read, write)` — no subprocess spawned. + return _FakeAsyncCM((object(), object())) + + +def _fake_client_session(*_args, **_kwargs): + # `async with ClientSession(...) as session` -> a session that hangs. + return _FakeAsyncCM(_HangingSession()) + + +class TestStdioInitializeTimeout: + def test_hanging_initialize_is_bounded_not_leaked(self): + """A stdio server that hangs at ``initialize`` must fail within + ``connect_timeout`` — not block ``_run_stdio`` forever (#59349).""" + from tools import mcp_tool + + server = mcp_tool.MCPServerTask("leak-guard") + config = {"command": "fake-mcp", "args": [], "connect_timeout": 0.2} + + async def drive(): + with patch.object(mcp_tool, "stdio_client", _fake_stdio_client), \ + patch.object(mcp_tool, "ClientSession", _fake_client_session), \ + patch.object(mcp_tool, "_resolve_stdio_command", lambda c, e: (c, e)), \ + patch.object(mcp_tool, "_write_stderr_log_header", lambda *_a, **_k: None), \ + patch.object(mcp_tool, "_get_mcp_stderr_log", lambda: None), \ + patch("tools.osv_check.check_package_for_malware", + lambda *_a, **_k: None): + start = time.monotonic() + # The outer 5s guard exists ONLY so a regression can't hang the + # whole suite. With the fix, the inner connect_timeout (0.2s) + # fires first; the elapsed assertion below is what actually + # distinguishes "bounded" (fixed) from "hung" (regressed). + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(server._run_stdio(config), timeout=5.0) + return time.monotonic() - start + + elapsed = asyncio.run(drive()) + assert elapsed < 2.0, ( + f"_run_stdio blocked {elapsed:.1f}s on a hanging initialize() — the " + f"connect_timeout ({config['connect_timeout']}s) bound was not applied; " + f"the #59349 subprocess/FD leak has regressed." + ) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 1861e2b352a6..55d9eead4f70 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -2007,7 +2007,23 @@ class MCPServerTask: async with ClientSession( read_stream, write_stream, **sampling_kwargs ) as session: - self.initialize_result = await session.initialize() + # Bound the MCP handshake. A stdio server that never + # completes ``initialize`` (e.g. emits a non-JSON-RPC frame + # and then blocks on stdin) otherwise hangs this coroutine + # forever on the background loop: ``connect_timeout`` only + # bounds the caller's ``.result()`` wait, not the coroutine + # itself. Because the connect never unwinds, the cleanup + # ``finally`` below never runs, so the spawned child and its + # stdio pipes/pidfd leak on every discovery retry — unbounded + # until the gateway hits EMFILE. Timing out here converts the + # hang into a normal failure, letting the ``finally`` reap the + # child. See #59349. + connect_timeout = float( + config.get("connect_timeout", _DEFAULT_CONNECT_TIMEOUT) + ) + self.initialize_result = await asyncio.wait_for( + session.initialize(), timeout=connect_timeout + ) self.session = session await self._discover_tools() self._ready.set() From 4638f3b433b9e7d498912c0a99226ec737039825 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:19:53 -0700 Subject: [PATCH 162/610] fix(mcp): widen #59349 handshake bound to HTTP transports + cancel abandoned start() task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling sites of the same bug class as the salvaged stdio fix: - SSE, streamable-HTTP (new + deprecated API) initialize() calls are now bounded by the same connect_timeout, so an endpoint that accepts the connection but never answers the handshake cannot park the run() task forever. - start() now cancels its ensure_future'd run() task when the caller's connect timeout cancels start() itself — the orphaned-task leak was the root mechanism behind #59349, and this closes the class for any future pre-ready hang. --- tools/mcp_tool.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 55d9eead4f70..3aff482f2e7c 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -2312,7 +2312,13 @@ class MCPServerTask: async with ClientSession( read_stream, write_stream, **sampling_kwargs ) as session: - self.initialize_result = await session.initialize() + # Bound the handshake — same orphaned-task hang as the + # stdio path (#59349): an endpoint that accepts the + # connection but never answers ``initialize`` parks this + # coroutine forever on the background loop. + self.initialize_result = await asyncio.wait_for( + session.initialize(), timeout=float(connect_timeout) + ) self.session = session await self._discover_tools() self._ready.set() @@ -2366,7 +2372,10 @@ class MCPServerTask: read_stream, write_stream, _get_session_id, ): async with ClientSession(read_stream, write_stream, **sampling_kwargs) as session: - self.initialize_result = await session.initialize() + # Bound the handshake (#59349) — see stdio path. + self.initialize_result = await asyncio.wait_for( + session.initialize(), timeout=float(connect_timeout) + ) self.session = session await self._discover_tools() self._ready.set() @@ -2394,7 +2403,10 @@ class MCPServerTask: read_stream, write_stream, _get_session_id, ): async with ClientSession(read_stream, write_stream, **sampling_kwargs) as session: - self.initialize_result = await session.initialize() + # Bound the handshake (#59349) — see stdio path. + self.initialize_result = await asyncio.wait_for( + session.initialize(), timeout=float(connect_timeout) + ) self.session = session await self._discover_tools() self._ready.set() @@ -2715,7 +2727,19 @@ class MCPServerTask: async def start(self, config: dict): """Create the background Task and wait until ready (or failed).""" self._task = asyncio.ensure_future(self.run(config)) - await self._ready.wait() + try: + await self._ready.wait() + except asyncio.CancelledError: + # The caller's connect timeout (discover_mcp_tools wraps start() + # in asyncio.wait_for) cancels *this* coroutine, but the + # ensure_future'd run() task is independent and would otherwise + # keep running detached — parked on a hung transport with no + # owner to reap it (#59349). Propagate the cancellation so the + # transport context managers unwind and their finally blocks + # release the child process / FDs. + if self._task and not self._task.done(): + self._task.cancel() + raise if self._error: raise self._error From 5089c84dbf852a43ac879217d271ce3b8df9a3b6 Mon Sep 17 00:00:00 2001 From: Sage Date: Mon, 6 Jul 2026 22:16:42 -0700 Subject: [PATCH 163/610] fix(mcp): reap orphaned stdio MCP children on ungraceful parent death A stdio MCP server (e.g. `npx -y mcp-remote `) is spawned as a direct child of the Hermes process. Existing teardown (MCPServerTask.shutdown() / _kill_orphaned_mcp_children()) reaps it correctly on a clean exit, but a kill -9 / crash / force-quit of the Hermes process skips that path entirely -- the child (and its own descendants, e.g. mcp-remote's spawned node process) is orphaned and keeps running. Repeated ungraceful restarts pile up N orphaned processes racing to hold the same upstream SSE session, producing errors like 'Invalid request parameters' on legitimate reconnects. macOS/Linux have no portable equivalent of prctl(PR_SET_PDEATHSIG) at the Python subprocess level, so this adds a thin supervisor (tools/mcp_stdio_watchdog.py) that: - execs the real command as its own child in its own process group - passes stdin/stdout/stderr through untouched (MCP stdio protocol talks directly over those streams) - polls the original spawning PID with the same orphan-detection algorithm already proven in tui_gateway/slash_worker.py (ppid comparison + psutil creation-time guard against PID reuse) - SIGTERM-then-SIGKILL's the child's process group the moment the original parent is gone Wired into _run_stdio via a new _wrap_command_with_watchdog() helper, POSIX-only (matches the existing killpg-based cleanup's platform scope), fails open (any error resolving pid/create-time falls back to the unwrapped command) so this can never be the reason a working MCP server stops starting. Verified: reproduced the exact orphan scenario standalone (fake parent process spawns watchdog + fake long-running MCP child, kill -9 the fake parent, confirm the watchdog reaps the child within its poll window with zero leaked processes). Updated test_mcp_tool_issue_948.py's resolved-path assertion to check the watchdog-wrapped command instead of the raw resolved binary. Full test_mcp_tool.py + test_mcp_stability.py + test_mcp_tool_issue_948.py suite: 232 passed. Full -k mcp sweep across the whole test tree: 1003 passed, 2 skipped, 0 failed. --- tests/tools/test_mcp_tool_issue_948.py | 13 ++- tools/mcp_stdio_watchdog.py | 156 +++++++++++++++++++++++++ tools/mcp_tool.py | 45 +++++++ 3 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 tools/mcp_stdio_watchdog.py diff --git a/tests/tools/test_mcp_tool_issue_948.py b/tests/tools/test_mcp_tool_issue_948.py index c258cd570c76..eadb7397a409 100644 --- a/tests/tools/test_mcp_tool_issue_948.py +++ b/tests/tools/test_mcp_tool_issue_948.py @@ -1,5 +1,6 @@ import asyncio import os +import sys from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -119,8 +120,18 @@ def test_run_stdio_uses_resolved_command_and_prepended_path(tmp_path): server = MCPServerTask("srv") await server.start({"command": "npx", "args": ["-y", "pkg"], "env": {"PATH": "/usr/bin"}}) + # The real (resolved) command no longer reaches StdioServerParameters + # directly -- it's now wrapped in the parent-death watchdog + # supervisor (tools/mcp_stdio_watchdog.py) so an ungraceful exit of + # this process can't orphan it. Assert the resolved npx path and + # its args still flow through correctly as the watchdog's target + # command, preserving this test's original path-resolution intent. call_kwargs = mock_params.call_args.kwargs - assert call_kwargs["command"] == str(npx_path) + assert call_kwargs["command"] == sys.executable + assert call_kwargs["args"][0].endswith("mcp_stdio_watchdog.py") + assert "--" in call_kwargs["args"] + sep = call_kwargs["args"].index("--") + assert call_kwargs["args"][sep + 1:] == [str(npx_path), "-y", "pkg"] assert call_kwargs["env"]["PATH"].split(os.pathsep)[0] == str(node_bin) await server.shutdown() diff --git a/tools/mcp_stdio_watchdog.py b/tools/mcp_stdio_watchdog.py new file mode 100644 index 000000000000..46761c163374 --- /dev/null +++ b/tools/mcp_stdio_watchdog.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Parent-death watchdog supervisor for stdio MCP subprocesses. + +Problem this fixes (#TBD): a stdio MCP server (e.g. ``npx -y mcp-remote +``) is spawned as a direct child of the Hermes process. Hermes's own +teardown path (``MCPServerTask.shutdown()`` / ``_kill_orphaned_mcp_children`` +at final exit) reaps it cleanly on a *graceful* exit. But if the spawning +Hermes process dies hard — ``kill -9``, an OS-level crash, a force-quit of +the TUI/desktop app — that teardown code never runs, and the child (plus any +of its own descendants, e.g. mcp-remote's spawned ``node`` process) is +orphaned. macOS has no direct equivalent of Linux's +``prctl(PR_SET_PDEATHSIG)`` to make the kernel auto-kill a child when its +parent dies, so nothing reaps these until the next Hermes startup's opt-in +``_kill_orphaned_mcp_children()`` sweep — which only runs if something calls +it. Repeated ungraceful session restarts can pile up N orphaned processes, +all racing to hold the same upstream SSE session, producing errors like +"Invalid request parameters" / "Received request before initialization was +complete" on the *legitimate* new connection. + +Fix: don't spawn the MCP server command directly. Spawn this supervisor +instead, which: + 1. execs the real command as its own child (own process group via + ``start_new_session``, so it doesn't inherit the supervisor's + controlling terminal weirdly and so we can killpg it cleanly); + 2. transparently passes stdin/stdout/stderr through — the MCP stdio + protocol talks directly over those pipes, so the supervisor must be a + no-op relay, not a bytes-in-the-middle proxy; + 3. runs a background thread that polls the ORIGINAL parent PID using the + exact same orphan-detection algorithm already proven in + ``tui_gateway/slash_worker.py`` (``_is_orphaned``): compare current + ``getppid()`` against the recorded original, and guard PID reuse via + ``psutil`` process creation time; + 4. the instant the original parent is gone, terminates the real child's + process group (SIGTERM, grace period, then SIGKILL) and exits. + +This is intentionally a thin, dependency-light script (``psutil`` only, +already a hard dependency via ``tui_gateway/slash_worker.py``) so it starts +fast and can't itself become a resource leak. + +Usage (see ``tools/mcp_tool.py::_run_stdio``):: + + python3 -m tools.mcp_stdio_watchdog \\ + --ppid --create-time \\ + -- ... +""" + +from __future__ import annotations + +import argparse +import os +import signal +import subprocess +import sys +import threading +import time + +try: + import psutil +except ImportError: # pragma: no cover - psutil is a hard dependency elsewhere + psutil = None + +_POLL_INTERVAL_S = 2.0 +_TERM_GRACE_S = 3.0 + + +def _is_orphaned(original_ppid: int, parent_create_time: float, getppid=os.getppid) -> bool: + """Mirrors ``tui_gateway.slash_worker._is_orphaned`` exactly. + + True once the process that spawned us is gone. Never trusts a bare + ``getppid() == 1`` check (Linux reparents orphans to a subreaper, not + always PID 1), and guards against PID reuse via the recorded creation + time of the original parent. + """ + if getppid() != original_ppid: + return True + if psutil is None: + # No reliable staleness check available; fall back to the ppid + # comparison alone (still catches the common case). + return False + try: + if not psutil.pid_exists(original_ppid): + return True + return psutil.Process(original_ppid).create_time() != parent_create_time + except psutil.Error: + return True + + +def _terminate_process_group(proc: subprocess.Popen) -> None: + """Best-effort SIGTERM-then-SIGKILL of the child's process group.""" + try: + pgid = os.getpgid(proc.pid) + except (ProcessLookupError, OSError): + return + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(pgid, sig) + except (ProcessLookupError, PermissionError, OSError): + return + try: + proc.wait(timeout=_TERM_GRACE_S) + return + except subprocess.TimeoutExpired: + continue + + +def _watchdog_loop(proc: subprocess.Popen, original_ppid: int, parent_create_time: float) -> None: + while proc.poll() is None: + if _is_orphaned(original_ppid, parent_create_time): + _terminate_process_group(proc) + return + time.sleep(_POLL_INTERVAL_S) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Parent-death watchdog for a stdio MCP subprocess.", + ) + parser.add_argument("--ppid", type=int, required=True) + parser.add_argument("--create-time", type=float, required=True) + parser.add_argument("command", nargs=argparse.REMAINDER) + args = parser.parse_args(argv) + + real_argv = list(args.command) + if real_argv and real_argv[0] == "--": + real_argv = real_argv[1:] + if not real_argv: + print("mcp_stdio_watchdog: no command given after '--'", file=sys.stderr) + return 2 + + # New process group so we can killpg() the whole tree the real command + # may spawn (e.g. mcp-remote's own child `node` process), without + # touching our own group or the (already-gone) original parent's. + proc = subprocess.Popen( + real_argv, + stdin=sys.stdin, + stdout=sys.stdout, + stderr=sys.stderr, + start_new_session=True, + ) + + watchdog = threading.Thread( + target=_watchdog_loop, + args=(proc, args.ppid, args.create_time), + daemon=True, + ) + watchdog.start() + + try: + return proc.wait() + except KeyboardInterrupt: + _terminate_process_group(proc) + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 3aff482f2e7c..1d45a0790273 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -616,6 +616,41 @@ def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]: return resolved_command, resolved_env +def _wrap_command_with_watchdog(command: str, args: list) -> tuple[str, list]: + """Wrap a stdio MCP server command in the parent-death watchdog supervisor. + + See ``tools/mcp_stdio_watchdog.py`` module docstring for the full + rationale. Returns the (command, args) unchanged on any platform/failure + where the wrap can't safely apply, so this can never be the reason a + previously-working MCP server stops starting. + """ + if os.name != "posix": + # Relies on process groups (os.getpgid/os.killpg); no POSIX + # equivalent wired up here yet, matching the existing killpg-based + # orphan cleanup's platform scope (Windows falls back to plain + # os.kill there too). + return command, args + try: + my_pid = os.getpid() + try: + import psutil + create_time = psutil.Process(my_pid).create_time() + except ImportError: + create_time = time.time() + except Exception: + # Never let watchdog bookkeeping failure block a real MCP connection. + return command, args + watchdog_args = [ + os.path.join(os.path.dirname(os.path.abspath(__file__)), "mcp_stdio_watchdog.py"), + "--ppid", str(my_pid), + "--create-time", repr(create_time), + "--", + command, + *args, + ] + return sys.executable, watchdog_args + + # --------------------------------------------------------------------------- # MCP ImageContent block → Hermes MEDIA tag # --------------------------------------------------------------------------- @@ -1914,6 +1949,16 @@ class MCPServerTask: safe_env = _build_safe_env(user_env) command, safe_env = _resolve_stdio_command(command, safe_env) + # Wrap the real command in a parent-death watchdog supervisor so an + # ungraceful exit of this Hermes process (kill -9, crash, force-quit) + # can't leave the stdio MCP child (and its own descendants, e.g. + # mcp-remote's spawned `node`) running forever. On a clean exit, + # MCPServerTask.shutdown() / _kill_orphaned_mcp_children() still do + # the reaping as before -- this only covers the case where that code + # never gets to run. POSIX-only (relies on process groups); no-op + # elsewhere, matching existing killpg-based cleanup's platform scope. + command, args = _wrap_command_with_watchdog(command, args) + # Check package against OSV malware database before spawning. # Run off the event loop (the urllib HTTPS call is blocking) and bound # it with a wall-clock timeout so a stalled SSL handshake can't freeze From 86c5febdd1d31200c6fa982c2e98eb27c116e876 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:23:34 -0700 Subject: [PATCH 164/610] fix(mcp): watchdog wrap after OSV preflight + forward SIGTERM to child group Two fixes on top of the salvaged parent-death watchdog: - Apply the watchdog wrap AFTER the OSV malware preflight so the check inspects the real npx/uvx package instead of the python wrapper (the wrap previously made the preflight a silent no-op for every stdio server). - The real server runs in its own process group under the watchdog, so the graceful-shutdown killpg no longer reached it; the watchdog now forwards SIGTERM/SIGINT to the child's group, keeping wedged servers killable on clean shutdown. --- tools/mcp_stdio_watchdog.py | 13 +++++++++++++ tools/mcp_tool.py | 25 +++++++++++++++---------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/tools/mcp_stdio_watchdog.py b/tools/mcp_stdio_watchdog.py index 46761c163374..17e6fb4c41c9 100644 --- a/tools/mcp_stdio_watchdog.py +++ b/tools/mcp_stdio_watchdog.py @@ -138,6 +138,19 @@ def main(argv: list[str] | None = None) -> int: start_new_session=True, ) + # Because the real server lives in its OWN process group (above), the + # parent's graceful-shutdown killpg of *our* group no longer reaches it. + # Forward SIGTERM/SIGINT to the child's group so graceful teardown + # (`_kill_orphaned_mcp_children`, shutdown sweeps) still kills a wedged + # server that ignores stdin EOF — otherwise the watchdog wrap would + # invert the bug it fixes. + def _forward_shutdown(signum, frame): # noqa: ARG001 + _terminate_process_group(proc) + sys.exit(128 + signum) + + signal.signal(signal.SIGTERM, _forward_shutdown) + signal.signal(signal.SIGINT, _forward_shutdown) + watchdog = threading.Thread( target=_watchdog_loop, args=(proc, args.ppid, args.create_time), diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 1d45a0790273..5f5726d769b3 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1949,21 +1949,14 @@ class MCPServerTask: safe_env = _build_safe_env(user_env) command, safe_env = _resolve_stdio_command(command, safe_env) - # Wrap the real command in a parent-death watchdog supervisor so an - # ungraceful exit of this Hermes process (kill -9, crash, force-quit) - # can't leave the stdio MCP child (and its own descendants, e.g. - # mcp-remote's spawned `node`) running forever. On a clean exit, - # MCPServerTask.shutdown() / _kill_orphaned_mcp_children() still do - # the reaping as before -- this only covers the case where that code - # never gets to run. POSIX-only (relies on process groups); no-op - # elsewhere, matching existing killpg-based cleanup's platform scope. - command, args = _wrap_command_with_watchdog(command, args) - # Check package against OSV malware database before spawning. # Run off the event loop (the urllib HTTPS call is blocking) and bound # it with a wall-clock timeout so a stalled SSL handshake can't freeze # MCP discovery / gateway startup (#29184). The check is fail-open, so # on timeout we log and proceed rather than blocking indefinitely. + # NOTE: must run against the REAL command/args — the watchdog wrap + # below rewrites argv to `python -m tools.mcp_stdio_watchdog …`, + # which would silently turn the preflight into a no-op. from tools.osv_check import check_package_for_malware try: malware_error = await asyncio.wait_for( @@ -1982,6 +1975,18 @@ class MCPServerTask: f"MCP server '{self.name}': {malware_error}" ) + # Wrap the real command in a parent-death watchdog supervisor so an + # ungraceful exit of this Hermes process (kill -9, crash, force-quit) + # can't leave the stdio MCP child (and its own descendants, e.g. + # mcp-remote's spawned `node`) running forever. On a clean exit, + # MCPServerTask.shutdown() / _kill_orphaned_mcp_children() still do + # the reaping as before -- this only covers the case where that code + # never gets to run. POSIX-only (relies on process groups); no-op + # elsewhere, matching existing killpg-based cleanup's platform scope. + # Applied AFTER the OSV preflight so the check inspects the real + # package, not the watchdog wrapper. + command, args = _wrap_command_with_watchdog(command, args) + server_params = StdioServerParameters( command=command, args=args, From 6c731fe591455e54373680b98502835d1d1577c2 Mon Sep 17 00:00:00 2001 From: harjoth Date: Mon, 8 Jun 2026 11:26:18 -0700 Subject: [PATCH 165/610] Recycle idle MCP stdio servers --- tests/tools/test_mcp_tool.py | 181 ++++++++++++++++++++++++++++ tools/mcp_tool.py | 221 ++++++++++++++++++++++++++++++++--- 2 files changed, 386 insertions(+), 16 deletions(-) diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index eeeeef7e48cb..a27baf5e79b1 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -172,6 +172,56 @@ class TestMCPStatus: assert statuses["disabled"]["disabled"] is True +class TestLifecycleConfig: + def test_get_lifecycle_seconds_accepts_top_level_and_nested_values(self): + from tools.mcp_tool import _get_lifecycle_seconds + + assert ( + _get_lifecycle_seconds( + {"idle_timeout_seconds": "3.5"}, + "idle_timeout_seconds", + ) + == 3.5 + ) + assert _get_lifecycle_seconds( + {"lifecycle": {"max_lifetime_seconds": 42}}, + "max_lifetime_seconds", + ) == 42.0 + + def test_get_lifecycle_seconds_treats_zero_as_disabled(self): + from tools.mcp_tool import _get_lifecycle_seconds + + assert ( + _get_lifecycle_seconds( + {"idle_timeout_seconds": 0}, + "idle_timeout_seconds", + ) + is None + ) + + def test_get_lifecycle_seconds_ignores_invalid_values(self, caplog): + from tools.mcp_tool import _get_lifecycle_seconds + + assert ( + _get_lifecycle_seconds( + {"idle_timeout_seconds": "soon"}, + "idle_timeout_seconds", + ) + is None + ) + assert ( + _get_lifecycle_seconds( + {"idle_timeout_seconds": -1}, + "idle_timeout_seconds", + ) + is None + ) + + messages = [record.getMessage() for record in caplog.records] + assert any("must be a number of seconds" in msg for msg in messages) + assert any("must be positive" in msg for msg in messages) + + # --------------------------------------------------------------------------- # Schema conversion # --------------------------------------------------------------------------- @@ -568,6 +618,19 @@ class TestCheckFunction: finally: _servers.pop("test_server", None) + def test_recycled_stdio_server_remains_available_for_lazy_reconnect(self): + from tools.mcp_tool import _make_check_fn, _servers + + server = _make_mock_server("test_server", session=None) + server._config = {"command": "npx"} + server._recycled_reason = "idle_timeout_seconds" + _servers["test_server"] = server + try: + check = _make_check_fn("test_server") + assert check() is True + finally: + _servers.pop("test_server", None) + # --------------------------------------------------------------------------- # MCP loop runner @@ -742,6 +805,36 @@ class TestToolHandler: finally: _servers.pop("test_srv", None) + def test_recycled_stdio_server_reconnects_lazily_on_tool_call(self): + from tools.mcp_tool import _make_tool_handler, _servers + + mock_session = MagicMock() + mock_session.call_tool = AsyncMock( + return_value=_make_call_result("reconnected", is_error=False) + ) + server = _make_mock_server("test_srv", session=None) + server._config = {"command": "npx"} + server._recycled_reason = "idle_timeout_seconds" + _servers["test_srv"] = server + + def fake_lazy_reconnect(server_name, srv): + assert server_name == "test_srv" + assert srv is server + srv.session = mock_session + srv._recycled_reason = None + return True + + try: + handler = _make_tool_handler("test_srv", "greet", 120) + with patch("tools.mcp_tool._request_lazy_reconnect", side_effect=fake_lazy_reconnect) as reconnect, \ + self._patch_mcp_loop(): + result = json.loads(handler({"name": "world"})) + assert result["result"] == "reconnected" + reconnect.assert_called_once() + mock_session.call_tool.assert_called_once_with("greet", arguments={"name": "world"}) + finally: + _servers.pop("test_srv", None) + class TestRunOnMCPLoopInterrupts: def test_interrupt_cancels_waiting_mcp_call(self): @@ -1148,6 +1241,52 @@ class TestMCPServerTask: asyncio.run(_test()) + def test_wait_for_lifecycle_event_recycles_idle_stdio_server(self): + from tools.mcp_tool import MCPServerTask + + async def _test(): + server = MCPServerTask("srv") + server._config = {"command": "npx"} + server.session = MagicMock() + server._idle_timeout_seconds = 0.01 + server._last_tool_call_at = time.monotonic() - 1.0 + + reason = await server._wait_for_lifecycle_event() + + assert reason == "recycle" + assert server._recycled_reason == "idle_timeout_seconds" + assert server.session is None + + asyncio.run(_test()) + + def test_stdio_recycle_reason_uses_max_lifetime(self): + from tools.mcp_tool import MCPServerTask + + async def _test(): + server = MCPServerTask("srv") + server._config = {"command": "npx"} + server._max_lifetime_seconds = 0.01 + server._lifecycle_started_at = time.monotonic() - 1.0 + + assert server._stdio_recycle_reason() == "max_lifetime_seconds" + + asyncio.run(_test()) + + def test_stdio_recycle_deadline_pauses_while_rpc_active(self): + from tools.mcp_tool import MCPServerTask + + async def _test(): + server = MCPServerTask("srv") + server._config = {"command": "npx"} + server._idle_timeout_seconds = 0.01 + server._last_tool_call_at = time.monotonic() - 1.0 + + async with server._rpc_lock: + assert server._stdio_recycle_reason() is None + assert server._next_stdio_recycle_deadline() is None + + asyncio.run(_test()) + # --------------------------------------------------------------------------- # discover_mcp_tools toolset injection @@ -2000,6 +2139,48 @@ class TestReconnection: asyncio.run(_test()) + def test_failed_recycle_reconnect_no_longer_checks_available(self): + """A failed lazy reconnect should not leave recycled availability true.""" + from tools.mcp_tool import ( + MCPServerTask, + _MAX_INITIAL_CONNECT_RETRIES, + _make_check_fn, + _servers, + ) + + run_count = 0 + target_server = None + + original_run_stdio = MCPServerTask._run_stdio + + async def patched_run_stdio(self_srv, config): + nonlocal run_count, target_server + run_count += 1 + if target_server is not self_srv: + return await original_run_stdio(self_srv, config) + raise ConnectionError("recycle reconnect failed") + + async def _test(): + nonlocal target_server + server = MCPServerTask("test_srv") + target_server = server + server._config = {"command": "test"} + server._ready.clear() + server._recycled_reason = "idle_timeout_seconds" + _servers["test_srv"] = server + try: + with patch.object(MCPServerTask, "_run_stdio", patched_run_stdio), \ + patch("asyncio.sleep", new_callable=AsyncMock): + await server.run({"command": "test"}) + + assert run_count == _MAX_INITIAL_CONNECT_RETRIES + 1 + assert server._recycled_reason is None + assert _make_check_fn("test_srv")() is False + finally: + _servers.pop("test_srv", None) + + asyncio.run(_test()) + # --------------------------------------------------------------------------- # Configurable timeouts diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 5f5726d769b3..114fc172fc14 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -23,6 +23,10 @@ Example config:: # 180). Set below the server's session TTL for # servers that GC idle sessions quickly (e.g. # Unreal Engine editor MCP, ~15s). Floored at 5s. + idle_timeout_seconds: 3600 # optional stdio recycle after idle + max_lifetime_seconds: 86400 # optional stdio recycle after age + # The recycle settings may also live under lifecycle: {...}. + # Use 0 to disable either recycle limit. github: command: "npx" args: ["-y", "@modelcontextprotocol/server-github"] @@ -330,6 +334,7 @@ _MAX_BACKOFF_SECONDS = 60 # server is unrevivable: its tools are out of the registry, so no tool call # can ever reach the circuit-breaker half-open probe or _signal_reconnect. _PARKED_RETRY_INTERVAL = 300 # seconds between parked self-probes +_RECYCLED_RECONNECT_TIMEOUT = 15.0 # Keepalive cadence for HTTP/SSE sessions. The MCP spec lets a server expire # idle sessions on any TTL it chooses (Streamable HTTP "Session Management"), @@ -1518,6 +1523,8 @@ class MCPServerTask: "_registered_tool_names", "_auth_type", "_refresh_lock", "_rpc_lock", "_pending_refresh_tasks", "_pending_call_context", + "_lifecycle_started_at", "_last_tool_call_at", + "_idle_timeout_seconds", "_max_lifetime_seconds", "_recycled_reason", "initialize_result", "_ping_unsupported", "_reconnect_retries", ) @@ -1562,6 +1569,12 @@ class MCPServerTask: # gateway-platform attribution and routes the approval prompt # to the right surface (Telegram, Slack, etc.). self._pending_call_context: Optional[contextvars.Context] = None + now = time.monotonic() + self._lifecycle_started_at: float = now + self._last_tool_call_at: float = now + self._idle_timeout_seconds: Optional[float] = None + self._max_lifetime_seconds: Optional[float] = None + self._recycled_reason: Optional[str] = None # Captures the ``InitializeResult`` returned by # ``await session.initialize()`` so downstream code can inspect the # server's real advertised capabilities (``.capabilities.resources``, @@ -1599,6 +1612,53 @@ class MCPServerTask: return True return getattr(caps, "tools", None) is not None + def _is_recycled_stdio(self) -> bool: + """Return True when a stdio server was intentionally recycled.""" + return not self._is_http() and self._recycled_reason is not None + + def mark_tool_call(self) -> None: + """Record that a user-visible MCP operation is starting.""" + self._last_tool_call_at = time.monotonic() + + def _mark_lifecycle_started(self) -> None: + now = time.monotonic() + self._lifecycle_started_at = now + self._last_tool_call_at = now + self._recycled_reason = None + + def _stdio_recycle_reason(self, now: Optional[float] = None) -> Optional[str]: + """Return the stdio recycle reason if idle/age limits have elapsed.""" + if self._is_http() or self._rpc_lock.locked(): + return None + now = time.monotonic() if now is None else now + if ( + self._max_lifetime_seconds is not None + and now - self._lifecycle_started_at >= self._max_lifetime_seconds + ): + return "max_lifetime_seconds" + if ( + self._idle_timeout_seconds is not None + and now - self._last_tool_call_at >= self._idle_timeout_seconds + ): + return "idle_timeout_seconds" + return None + + def _next_stdio_recycle_deadline(self) -> Optional[float]: + """Return the next monotonic recycle deadline for stdio, if any.""" + if self._is_http() or self._rpc_lock.locked(): + return None + deadlines = [] + if self._max_lifetime_seconds is not None: + deadlines.append(self._lifecycle_started_at + self._max_lifetime_seconds) + if self._idle_timeout_seconds is not None: + deadlines.append(self._last_tool_call_at + self._idle_timeout_seconds) + return min(deadlines) if deadlines else None + + def _mark_stdio_recycled(self, reason: str) -> None: + """Mark a stdio session dormant before its transport finishes closing.""" + self._recycled_reason = reason + self.session = None + # ----- Dynamic tool discovery (notifications/tools/list_changed) ----- async def _refresh_tools_task(self): @@ -1811,6 +1871,9 @@ class MCPServerTask: tokens, new session ID, etc.). The reconnect event is cleared before return so the next cycle starts with a fresh signal. + "recycle" if a stdio idle/max-lifetime limit elapsed. The + current transport is torn down and restarted lazily + on the next tool call. Shutdown takes precedence if both events are set simultaneously. @@ -1842,14 +1905,29 @@ class MCPServerTask: reconnect_task = asyncio.create_task(self._reconnect_event.wait()) try: while True: + recycle_reason = self._stdio_recycle_reason() + if recycle_reason is not None: + self._mark_stdio_recycled(recycle_reason) + return "recycle" + + timeout = keepalive_interval + recycle_deadline = self._next_stdio_recycle_deadline() + if recycle_deadline is not None: + timeout = max(0.0, min(timeout, recycle_deadline - time.monotonic())) + done, _pending = await asyncio.wait( {shutdown_task, reconnect_task}, - timeout=keepalive_interval, + timeout=timeout, return_when=asyncio.FIRST_COMPLETED, ) if done: break + recycle_reason = self._stdio_recycle_reason() + if recycle_reason is not None: + self._mark_stdio_recycled(recycle_reason) + return "recycle" + # Timeout — no lifecycle event fired. Probe the connection # to detect stale/expired sessions. Prefer ``ping`` (MCP base # protocol liveness): it works uniformly and stays a few bytes @@ -2075,6 +2153,7 @@ class MCPServerTask: session.initialize(), timeout=connect_timeout ) self.session = session + self._mark_lifecycle_started() await self._discover_tools() self._ready.set() # Session is live again: clear any breaker state from a @@ -2088,7 +2167,7 @@ class MCPServerTask: # stdio transport does not use OAuth, but we still honor # _reconnect_event (e.g. future manual /mcp refresh) for # consistency with _run_http. - await self._wait_for_lifecycle_event() + return await self._wait_for_lifecycle_event() finally: # Runs on clean exit, exceptions, AND asyncio cancellation. # If any of the spawned PIDs are still alive, the SDK's @@ -2383,7 +2462,7 @@ class MCPServerTask: "MCP server '%s': reconnect requested — " "tearing down SSE session", self.name, ) - return + return reason if _MCP_NEW_HTTP: # New API (mcp >= 1.24.0): build an explicit httpx.AsyncClient @@ -2440,6 +2519,7 @@ class MCPServerTask: "MCP server '%s': reconnect requested — " "tearing down HTTP session", self.name, ) + return reason else: # Deprecated API (mcp < 1.24.0): manages httpx client internally. _http_kwargs: dict = { @@ -2471,6 +2551,7 @@ class MCPServerTask: "MCP server '%s': reconnect requested — " "tearing down legacy HTTP session", self.name, ) + return reason async def _discover_tools(self): """Discover tools from the connected session. @@ -2531,6 +2612,8 @@ class MCPServerTask: self._config = config self.tool_timeout = config.get("timeout", _DEFAULT_TOOL_TIMEOUT) self._auth_type = (config.get("auth") or "").lower().strip() + self._idle_timeout_seconds = _get_lifecycle_seconds(config, "idle_timeout_seconds") + self._max_lifetime_seconds = _get_lifecycle_seconds(config, "max_lifetime_seconds") # Set up sampling handler if enabled and SDK types are available sampling_config = config.get("sampling", {}) @@ -2606,9 +2689,9 @@ class MCPServerTask: while True: try: if self._is_http(): - await self._run_http(config) + lifecycle_reason = await self._run_http(config) else: - await self._run_stdio(config) + lifecycle_reason = await self._run_stdio(config) # Transport returned cleanly. Two cases: # - _shutdown_event was set: exit the run loop entirely. # - _reconnect_event was set (auth recovery): loop back and @@ -2616,6 +2699,18 @@ class MCPServerTask: # touch the retry counters — this is not a failure. if self._shutdown_event.is_set(): break + if lifecycle_reason == "recycle": + logger.info( + "MCP server '%s': stdio session recycled after %s; " + "waiting for lazy reconnect", + self.name, self._recycled_reason, + ) + self.session = None + await self._wait_for_lazy_reconnect() + if self._shutdown_event.is_set(): + break + self._reconnect_event.clear() + continue logger.info( "MCP server '%s': reconnecting (OAuth recovery or " "manual refresh)", @@ -2651,6 +2746,13 @@ class MCPServerTask: raise except Exception as exc: self.session = None + if self._is_recycled_stdio(): + logger.warning( + "MCP server '%s': lazy reconnect after stdio recycle " + "failed, marking unavailable while retrying: %s", + self.name, exc, + ) + self._recycled_reason = None # If this is the first connection attempt, retry with backoff # before giving up. A transient DNS/network blip at startup @@ -2839,6 +2941,24 @@ class MCPServerTask: _forget_mcp_tool_server(tool_name) self._registered_tool_names = [] + async def _wait_for_lazy_reconnect(self) -> None: + """Wait while an intentionally recycled stdio server is dormant.""" + shutdown_task = asyncio.create_task(self._shutdown_event.wait()) + reconnect_task = asyncio.create_task(self._reconnect_event.wait()) + try: + await asyncio.wait( + {shutdown_task, reconnect_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + finally: + for task in (shutdown_task, reconnect_task): + if not task.done(): + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + # --------------------------------------------------------------------------- # Module-level state @@ -3684,6 +3804,51 @@ async def _connect_server(name: str, config: dict) -> MCPServerTask: # Handler / check-fn factories # --------------------------------------------------------------------------- +def _request_lazy_reconnect(server_name: str, server: MCPServerTask) -> bool: + """Wake a recycled stdio server and wait briefly for a fresh session.""" + if not server._is_recycled_stdio(): + return False + + with _lock: + loop = _mcp_loop + if loop is None or not loop.is_running(): + return False + + def _signal_reconnect() -> None: + server._ready.clear() + server._reconnect_event.set() + + loop.call_soon_threadsafe(_signal_reconnect) + + async def _await_ready() -> bool: + deadline = time.monotonic() + _RECYCLED_RECONNECT_TIMEOUT + while time.monotonic() < deadline: + if server.session is not None and server._ready.is_set(): + return True + await asyncio.sleep(0.05) + return False + + try: + return bool(_run_on_mcp_loop(_await_ready, timeout=_RECYCLED_RECONNECT_TIMEOUT)) + except Exception as exc: + logger.warning( + "MCP server '%s': lazy reconnect after stdio recycle failed: %s", + server_name, exc, + ) + return False + + +def _get_connected_server_for_call(server_name: str) -> Optional[MCPServerTask]: + """Return a connected server, lazily reconnecting recycled stdio state.""" + with _lock: + server = _servers.get(server_name) + if server is not None and server.session is None and server._is_recycled_stdio(): + _request_lazy_reconnect(server_name, server) + with _lock: + server = _servers.get(server_name) + return server + + def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float): """Return a sync handler that calls an MCP tool via the background loop. @@ -3718,8 +3883,7 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float): }, ensure_ascii=False) # Cooldown elapsed → fall through as a half-open probe. - with _lock: - server = _servers.get(server_name) + server = _get_connected_server_for_call(server_name) if not server: _bump_server_error(server_name) return json.dumps({ @@ -3761,6 +3925,7 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float): }, ensure_ascii=False) async def _call(): + server.mark_tool_call() async with server._rpc_lock: # Snapshot the agent's context so an elicitation callback # triggered during this call (fired on the MCP recv loop @@ -3874,14 +4039,14 @@ def _make_list_resources_handler(server_name: str, tool_timeout: float): """Return a sync handler that lists resources from an MCP server.""" def _handler(args: dict, **kwargs) -> str: - with _lock: - server = _servers.get(server_name) + server = _get_connected_server_for_call(server_name) if not server or not server.session: return json.dumps({ "error": f"MCP server '{server_name}' is not connected" }, ensure_ascii=False) async def _call(): + server.mark_tool_call() async with server._rpc_lock: result = await server.session.list_resources() resources = [] @@ -3934,8 +4099,7 @@ def _make_read_resource_handler(server_name: str, tool_timeout: float): def _handler(args: dict, **kwargs) -> str: from tools.registry import tool_error - with _lock: - server = _servers.get(server_name) + server = _get_connected_server_for_call(server_name) if not server or not server.session: return json.dumps({ "error": f"MCP server '{server_name}' is not connected" @@ -3946,6 +4110,7 @@ def _make_read_resource_handler(server_name: str, tool_timeout: float): return tool_error("Missing required parameter 'uri'") async def _call(): + server.mark_tool_call() async with server._rpc_lock: result = await server.session.read_resource(uri) # read_resource returns ReadResourceResult with .contents list @@ -3992,14 +4157,14 @@ def _make_list_prompts_handler(server_name: str, tool_timeout: float): """Return a sync handler that lists prompts from an MCP server.""" def _handler(args: dict, **kwargs) -> str: - with _lock: - server = _servers.get(server_name) + server = _get_connected_server_for_call(server_name) if not server or not server.session: return json.dumps({ "error": f"MCP server '{server_name}' is not connected" }, ensure_ascii=False) async def _call(): + server.mark_tool_call() async with server._rpc_lock: result = await server.session.list_prompts() prompts = [] @@ -4057,8 +4222,7 @@ def _make_get_prompt_handler(server_name: str, tool_timeout: float): def _handler(args: dict, **kwargs) -> str: from tools.registry import tool_error - with _lock: - server = _servers.get(server_name) + server = _get_connected_server_for_call(server_name) if not server or not server.session: return json.dumps({ "error": f"MCP server '{server_name}' is not connected" @@ -4070,6 +4234,7 @@ def _make_get_prompt_handler(server_name: str, tool_timeout: float): arguments = args.get("arguments", {}) async def _call(): + server.mark_tool_call() async with server._rpc_lock: result = await server.session.get_prompt(name, arguments=arguments) # GetPromptResult has .messages list @@ -4128,7 +4293,10 @@ def _make_check_fn(server_name: str): def _check() -> bool: with _lock: server = _servers.get(server_name) - return server is not None and server.session is not None + return ( + server is not None + and (server.session is not None or server._is_recycled_stdio()) + ) return _check @@ -4428,6 +4596,27 @@ def _parse_boolish(value: Any, default: bool = True) -> bool: return default +def _get_lifecycle_seconds(config: dict, key: str) -> Optional[float]: + """Return an optional positive lifecycle timeout from top-level/nested config.""" + raw = config.get(key) + lifecycle = config.get("lifecycle") + if raw is None and isinstance(lifecycle, dict): + raw = lifecycle.get(key) + if raw is None: + return None + try: + seconds = float(raw) + except (TypeError, ValueError): + logger.warning("MCP config %s must be a number of seconds; ignoring %r", key, raw) + return None + if seconds == 0: + return None + if seconds < 0: + logger.warning("MCP config %s must be positive; ignoring %r", key, raw) + return None + return seconds + + _UTILITY_CAPABILITY_METHODS = { "list_resources": "list_resources", "read_resource": "read_resource", From ea0b42c43ac35027663035deabafb2bc41878627 Mon Sep 17 00:00:00 2001 From: harjoth Date: Mon, 8 Jun 2026 11:37:23 -0700 Subject: [PATCH 166/610] Handle minimal MCP server fakes --- tests/tools/test_mcp_circuit_breaker.py | 7 +++++++ tools/mcp_tool.py | 17 ++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/tools/test_mcp_circuit_breaker.py b/tests/tools/test_mcp_circuit_breaker.py index 116af9397cdf..f356c94e1280 100644 --- a/tests/tools/test_mcp_circuit_breaker.py +++ b/tests/tools/test_mcp_circuit_breaker.py @@ -74,6 +74,13 @@ def _install_stub_server(mcp_tool_module, name: str, call_tool_impl): server._reconnect_event = _ReconnectAdapter() server._ready = _ReadyAdapter() + # A bare MagicMock returns a truthy Mock for every method, so + # ``_is_recycled_stdio()`` would spuriously report this stub as a recycled + # stdio server and divert dead-session tool calls into the lazy-reconnect + # wait (which polls the test-frozen ``time.monotonic`` forever). Real + # non-recycled servers return False here; make the stub faithful so the + # dead-session path falls through to the graceful reconnect handler. + server._is_recycled_stdio.return_value = False mcp_tool_module._servers[name] = server mcp_tool_module._server_error_counts.pop(name, None) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 114fc172fc14..cd999f2712f7 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -3849,6 +3849,13 @@ def _get_connected_server_for_call(server_name: str) -> Optional[MCPServerTask]: return server +def _mark_server_call_started(server: Any) -> None: + """Record a user-visible MCP operation when the server supports it.""" + mark_tool_call = getattr(server, "mark_tool_call", None) + if callable(mark_tool_call): + mark_tool_call() + + def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float): """Return a sync handler that calls an MCP tool via the background loop. @@ -3925,7 +3932,7 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float): }, ensure_ascii=False) async def _call(): - server.mark_tool_call() + _mark_server_call_started(server) async with server._rpc_lock: # Snapshot the agent's context so an elicitation callback # triggered during this call (fired on the MCP recv loop @@ -4046,7 +4053,7 @@ def _make_list_resources_handler(server_name: str, tool_timeout: float): }, ensure_ascii=False) async def _call(): - server.mark_tool_call() + _mark_server_call_started(server) async with server._rpc_lock: result = await server.session.list_resources() resources = [] @@ -4110,7 +4117,7 @@ def _make_read_resource_handler(server_name: str, tool_timeout: float): return tool_error("Missing required parameter 'uri'") async def _call(): - server.mark_tool_call() + _mark_server_call_started(server) async with server._rpc_lock: result = await server.session.read_resource(uri) # read_resource returns ReadResourceResult with .contents list @@ -4164,7 +4171,7 @@ def _make_list_prompts_handler(server_name: str, tool_timeout: float): }, ensure_ascii=False) async def _call(): - server.mark_tool_call() + _mark_server_call_started(server) async with server._rpc_lock: result = await server.session.list_prompts() prompts = [] @@ -4234,7 +4241,7 @@ def _make_get_prompt_handler(server_name: str, tool_timeout: float): arguments = args.get("arguments", {}) async def _call(): - server.mark_tool_call() + _mark_server_call_started(server) async with server._rpc_lock: result = await server.session.get_prompt(name, arguments=arguments) # GetPromptResult has .messages list From bae3954f4f7d1e4609e6128c45198e3a3bc42ca7 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:30:53 -0700 Subject: [PATCH 167/610] chore(release): map rainbowgore + thestudionorth in AUTHOR_MAP for MCP leak salvages --- scripts/release.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 119ef7e0391f..3a85bb557665 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,8 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "164521089+rainbowgits@users.noreply.github.com": "rainbowgore", # PR #59405 salvage (mcp: bound stdio initialize handshake to stop subprocess/FD leak; #59349) + "sage@Sages-Mac-mini.local": "thestudionorth", # PR #60015 salvage (mcp: parent-death watchdog for stdio children; commit under unlinked local identity) "4087127+vampyren@users.noreply.github.com": "vampyren", # PR #59830 salvage (kanban: grab-to-pan board scrolling; original commit under unlinked local identity) "spiky02plateau@users.noreply.github.com": "spiky02plateau", # PR #32824 salvage (usage: fetch Codex account limits from the credential pool in pool-only setups; superseded by #60028) "taylorhp@gmail.com": "hwrdprkns", # PR #36896 salvage (secrets: 1Password op:// secret source + shared _cache substrate, adapted onto the SecretSource interface) From a6203839b4ed3c344e188e811fab4e8c1c483b84 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:53:27 -0700 Subject: [PATCH 168/610] docs(mcp): document idle_timeout_seconds / max_lifetime_seconds recycle keys + handshake-bound note --- website/docs/user-guide/features/mcp.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/website/docs/user-guide/features/mcp.md b/website/docs/user-guide/features/mcp.md index fc0c27301f86..bf946fde10ec 100644 --- a/website/docs/user-guide/features/mcp.md +++ b/website/docs/user-guide/features/mcp.md @@ -296,7 +296,9 @@ Hermes reads MCP config from `~/.hermes/config.yaml` under `mcp_servers`. | `client_cert` | string \| list | Client certificate for mTLS — a combined PEM path, or `[cert, key]` / `[cert, key, password]` | | `client_key` | string | Client private-key PEM path (when separate from `client_cert`) | | `timeout` | number | Tool call timeout | -| `connect_timeout` | number | Initial connection timeout | +| `connect_timeout` | number | Initial connection timeout (also bounds the MCP `initialize` handshake) | +| `idle_timeout_seconds` | number | Recycle a stdio server after this many seconds without a tool call (`0` = never, default). The server restarts transparently on the next tool call. | +| `max_lifetime_seconds` | number | Recycle a stdio server after this total age (`0` = never, default). Restarts transparently on next use. | | `enabled` | bool | If `false`, Hermes skips the server entirely | | `supports_parallel_tool_calls` | bool | If `true`, tools from this server may run concurrently | | `tools` | mapping | Per-server tool filtering and utility policy | @@ -310,6 +312,23 @@ mcp_servers: args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] ``` +### Recycling memory-heavy stdio servers + +Browser-based MCP servers (e.g. `@playwright/mcp`) keep a full Chromium +resident after their first tool call — hundreds of MB that never get +released. Opt in to automatic recycling and the server is torn down after +the idle/lifetime limit, then restarted transparently the next time one of +its tools is called (its tools stay registered the whole time): + +```yaml +mcp_servers: + playwright: + command: "npx" + args: ["-y", "@playwright/mcp@latest", "--headless"] + idle_timeout_seconds: 900 # recycle after 15 min without a tool call + max_lifetime_seconds: 86400 # and at least once a day regardless +``` + ### Minimal HTTP example ```yaml From 2d4fd1d52f4884f3db60e5f662b5d057ddf301fc Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:06:21 -0700 Subject: [PATCH 169/610] test(mcp): unblock recycle-reconnect test from the parked self-probe wait The salvaged test predates the parked-server self-probe (_PARKED_RETRY_INTERVAL, landed on main after the PR branched): after the final failed retry, run() parks in a real asyncio.wait that the patched asyncio.sleep doesn't cover, stalling the test 300s. Signal shutdown once the retry budget is exhausted so the park exits immediately. --- tests/tools/test_mcp_tool.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index a27baf5e79b1..be41088e4eb7 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -2158,6 +2158,13 @@ class TestReconnection: run_count += 1 if target_server is not self_srv: return await original_run_stdio(self_srv, config) + # After the final retry, run() parks in + # _wait_for_reconnect_or_shutdown(timeout=_PARKED_RETRY_INTERVAL) + # (a real asyncio.wait — the patched asyncio.sleep doesn't cover + # it). Signal shutdown so the park exits immediately instead of + # blocking the test for the 300s self-probe interval. + if run_count > _MAX_INITIAL_CONNECT_RETRIES: + self_srv._shutdown_event.set() raise ConnectionError("recycle reconnect failed") async def _test(): From 838d50495f15713be8825615a6e713a3e2f3105d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:11:59 -0700 Subject: [PATCH 170/610] fix(mcp): guard POSIX-only kill primitives in stdio watchdog for the Windows footgun linter signal.SIGKILL / os.killpg don't exist on Windows. The watchdog is only spawned on POSIX (wrap site gates on os.name), but guard via getattr with a plain terminate/kill fallback so an accidental Windows import can't AttributeError. --- tools/mcp_stdio_watchdog.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tools/mcp_stdio_watchdog.py b/tools/mcp_stdio_watchdog.py index 17e6fb4c41c9..18c710402d9d 100644 --- a/tools/mcp_stdio_watchdog.py +++ b/tools/mcp_stdio_watchdog.py @@ -86,14 +86,29 @@ def _is_orphaned(original_ppid: int, parent_create_time: float, getppid=os.getpp def _terminate_process_group(proc: subprocess.Popen) -> None: - """Best-effort SIGTERM-then-SIGKILL of the child's process group.""" + """Best-effort SIGTERM-then-SIGKILL of the child's process group. + + This module only ever runs on POSIX (the wrap site in tools/mcp_tool.py + gates on ``os.name == "posix"``), but guard the POSIX-only primitives + anyway so an accidental Windows import/execute degrades to a plain + child kill instead of AttributeError. + """ + killpg = getattr(os, "killpg", None) + if killpg is None: # windows-footgun: ok — non-POSIX fallback + try: + proc.terminate() + proc.wait(timeout=_TERM_GRACE_S) + except (OSError, subprocess.TimeoutExpired): + proc.kill() + return try: pgid = os.getpgid(proc.pid) except (ProcessLookupError, OSError): return - for sig in (signal.SIGTERM, signal.SIGKILL): + sigkill = getattr(signal, "SIGKILL", signal.SIGTERM) + for sig in (signal.SIGTERM, sigkill): try: - os.killpg(pgid, sig) + killpg(pgid, sig) except (ProcessLookupError, PermissionError, OSError): return try: From b062083d0af8b1c1d893a7f950c7f93a046b8a35 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:13:03 -0700 Subject: [PATCH 171/610] feat(dashboard): report profile + gateway topology in /api/status (#60537) /api/status (loopback/insecure binds only) now includes: - profiles: every profile on the host (default + named) - gateway_mode: none | single | multiple | multiplex - gateways: one entry per live gateway with the host ports its port-binding platforms listen on, plus served_profiles when the default gateway is multiplexing Ports resolve from each profile's config.yaml (top-level platforms: wins over gateway.platforms:, matching load_gateway_config precedence) with adapter defaults as fallback. Topology enumeration runs in an executor so the profile scan + process-table probes stay off the event loop, and the whole block is gated behind the same loopback-only split as hermes_home/gateway_pid so gated binds leak nothing new. --- hermes_cli/web_server.py | 141 +++++++++++ .../test_web_server_gateway_topology.py | 220 ++++++++++++++++++ 2 files changed, 361 insertions(+) create mode 100644 tests/hermes_cli/test_web_server_gateway_topology.py diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 07a5ef619b44..4ae8c3251d2a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2252,6 +2252,136 @@ async def git_branch_switch_route(body: GitBranchSwitchBody): return await _git_op(_web_git.branch_switch, _git_path(body.path), body.branch) +# Host TCP ports each port-binding gateway platform listens on, as +# ``platform-name -> (config port key, adapter default)``. Mirrors +# ``_PORT_BINDING_PLATFORM_VALUES`` in gateway/run.py and each adapter's +# DEFAULT_PORT / DEFAULT_WEBHOOK_PORT constant. Used only for the dashboard's +# gateway-topology readout — best-effort display data, not a bind source. +_PORT_BINDING_PLATFORM_PORTS: Dict[str, Tuple[str, int]] = { + "webhook": ("port", 8644), + "api_server": ("port", 8642), + "msgraph_webhook": ("port", 8646), + "feishu": ("webhook_port", 8765), + "wecom_callback": ("port", 8645), + "bluebubbles": ("webhook_port", 8645), + "sms": ("webhook_port", 8080), + "whatsapp_cloud": ("webhook_port", 8090), + "line": ("port", 8646), +} + +# Platform states that mean the adapter is NOT serving its port right now. +_PLATFORM_DEAD_STATES = frozenset({"fatal", "disconnected", "stopped"}) + + +def _profile_platform_ports(profile_home: Path, runtime: Optional[dict]) -> Dict[str, int]: + """Best-effort map of ``platform -> host TCP port`` for one profile's gateway. + + Reads the platforms the running gateway reported in its + ``gateway_state.json`` and resolves each port-binding platform's port from + the profile's ``config.yaml`` (top-level ``platforms:`` wins over + ``gateway.platforms:``, matching ``load_gateway_config`` precedence), + falling back to the adapter default. Display-only: env-var port overrides + (e.g. ``WEBHOOK_PORT`` in that profile's .env) are not resolved here. + """ + platforms = (runtime or {}).get("platforms") or {} + active = [ + name for name, state in platforms.items() + if name in _PORT_BINDING_PLATFORM_PORTS + and isinstance(state, dict) + and state.get("state") not in _PLATFORM_DEAD_STATES + ] + if not active: + return {} + + blocks: Dict[str, dict] = {} + try: + with open(profile_home / "config.yaml", encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + gateway_cfg = cfg.get("gateway") if isinstance(cfg.get("gateway"), dict) else {} + # gateway.platforms first, top-level platforms second — later wins, + # matching the precedence in gateway.config.load_gateway_config(). + for src in ((gateway_cfg or {}).get("platforms"), cfg.get("platforms")): + if not isinstance(src, dict): + continue + for plat_name, plat_block in src.items(): + if isinstance(plat_block, dict): + blocks.setdefault(plat_name, {}).update(plat_block) + except Exception: + blocks = {} + + ports: Dict[str, int] = {} + for name in active: + port_key, default_port = _PORT_BINDING_PLATFORM_PORTS[name] + block = blocks.get(name) or {} + extra = block.get("extra") if isinstance(block.get("extra"), dict) else {} + raw = block.get(port_key, (extra or {}).get(port_key, default_port)) + try: + ports[name] = int(raw) + except (TypeError, ValueError): + ports[name] = default_port + return ports + + +def _collect_profile_gateway_topology() -> Dict[str, Any]: + """Enumerate profiles and the gateways serving them for ``/api/status``. + + Returns ``{"profiles": [...], "gateway_mode": ..., "gateways": [...]}``: + + * ``profiles`` — every profile on the host (default + named), from + ``profiles_to_serve(True)`` (the cheap enumeration chokepoint — no + per-profile config reads or skill counts). + * ``gateways`` — one entry per profile with a LIVE gateway process: + ``{"profile", "ports", "served_profiles"?}``. Liveness reuses + ``_check_gateway_running`` so this agrees with the profiles sidebar. + * ``gateway_mode`` — ``"multiplex"`` when the default gateway serves + multiple profiles (gateway.multiplex_profiles), ``"single"`` for one + live gateway, ``"multiple"`` for independent per-profile gateways, + ``"none"`` when nothing is running. + """ + try: + from hermes_cli.profiles import _check_gateway_running, profiles_to_serve + from gateway.status import read_runtime_status + homes = profiles_to_serve(True) + except Exception: + _log.debug("profile/gateway topology enumeration failed", exc_info=True) + return {"profiles": [], "gateway_mode": "unknown", "gateways": []} + + profile_names = [name for name, _home in homes] + gateways: List[Dict[str, Any]] = [] + multiplex = False + for name, home in homes: + try: + if not _check_gateway_running(home): + continue + except Exception: + continue + try: + runtime = read_runtime_status(home / "gateway_state.json") + except Exception: + runtime = None + served = [str(p) for p in ((runtime or {}).get("served_profiles") or [])] + if name == "default" and len(served) > 1: + multiplex = True + entry: Dict[str, Any] = { + "profile": name, + "ports": _profile_platform_ports(home, runtime), + } + if served: + entry["served_profiles"] = served + gateways.append(entry) + + if multiplex: + mode = "multiplex" + elif len(gateways) > 1: + mode = "multiple" + elif len(gateways) == 1: + mode = "single" + else: + mode = "none" + + return {"profiles": profile_names, "gateway_mode": mode, "gateways": gateways} + + @app.get("/api/status") async def get_status(profile: Optional[str] = None): status_scope = None @@ -2456,12 +2586,23 @@ async def get_status(profile: Optional[str] = None): # dashboard is local-only and the caller is already inside the trust # envelope — the same loopback/gated split ``should_require_auth`` draws. if not auth_required: + # Profile + gateway topology: which profiles exist, whether one + # multiplexed gateway or several per-profile gateways serve them, + # and which host ports the live gateways' port-binding platforms + # listen on. Enumerating profiles walks the filesystem and probes + # the process table, so keep it off the event loop. + topology = await asyncio.get_running_loop().run_in_executor( + None, _collect_profile_gateway_topology + ) status.update({ "hermes_home": str(get_hermes_home()), "config_path": str(get_config_path()), "env_path": str(get_env_path()), "gateway_pid": gateway_pid, "gateway_health_url": _GATEWAY_HEALTH_URL, + "profiles": topology["profiles"], + "gateway_mode": topology["gateway_mode"], + "gateways": topology["gateways"], }) return status diff --git a/tests/hermes_cli/test_web_server_gateway_topology.py b/tests/hermes_cli/test_web_server_gateway_topology.py new file mode 100644 index 000000000000..18de4aac72a4 --- /dev/null +++ b/tests/hermes_cli/test_web_server_gateway_topology.py @@ -0,0 +1,220 @@ +"""Tests for the /api/status profile + gateway topology readout. + +Covers the loopback-only ``profiles`` / ``gateway_mode`` / ``gateways`` fields +added to ``/api/status``: profile enumeration, single vs multiplex vs multiple +gateway detection, and per-platform port resolution. +""" + +import pytest + +from hermes_cli import web_server +from hermes_cli.web_server import ( + _collect_profile_gateway_topology, + _profile_platform_ports, +) + + +# --------------------------------------------------------------------------- +# _profile_platform_ports +# --------------------------------------------------------------------------- + +class TestProfilePlatformPorts: + def test_no_runtime_platforms_returns_empty(self, tmp_path): + assert _profile_platform_ports(tmp_path, None) == {} + assert _profile_platform_ports(tmp_path, {"platforms": {}}) == {} + + def test_non_port_binding_platform_ignored(self, tmp_path): + runtime = {"platforms": {"telegram": {"state": "connected"}}} + assert _profile_platform_ports(tmp_path, runtime) == {} + + def test_default_port_when_no_config(self, tmp_path): + runtime = {"platforms": {"webhook": {"state": "connected"}}} + assert _profile_platform_ports(tmp_path, runtime) == {"webhook": 8644} + + def test_port_from_config_yaml_top_level(self, tmp_path): + (tmp_path / "config.yaml").write_text( + "platforms:\n webhook:\n port: 9001\n", encoding="utf-8" + ) + runtime = {"platforms": {"webhook": {"state": "connected"}}} + assert _profile_platform_ports(tmp_path, runtime) == {"webhook": 9001} + + def test_port_from_gateway_platforms_block(self, tmp_path): + (tmp_path / "config.yaml").write_text( + "gateway:\n platforms:\n api_server:\n port: 9500\n", + encoding="utf-8", + ) + runtime = {"platforms": {"api_server": {"state": "connected"}}} + assert _profile_platform_ports(tmp_path, runtime) == {"api_server": 9500} + + def test_top_level_platforms_wins_over_gateway_block(self, tmp_path): + (tmp_path / "config.yaml").write_text( + "gateway:\n platforms:\n webhook:\n port: 1111\n" + "platforms:\n webhook:\n port: 2222\n", + encoding="utf-8", + ) + runtime = {"platforms": {"webhook": {"state": "connected"}}} + assert _profile_platform_ports(tmp_path, runtime) == {"webhook": 2222} + + def test_port_in_extra_block(self, tmp_path): + (tmp_path / "config.yaml").write_text( + "platforms:\n whatsapp_cloud:\n extra:\n webhook_port: 8095\n", + encoding="utf-8", + ) + runtime = {"platforms": {"whatsapp_cloud": {"state": "connected"}}} + assert _profile_platform_ports(tmp_path, runtime) == {"whatsapp_cloud": 8095} + + def test_dead_platform_states_excluded(self, tmp_path): + runtime = { + "platforms": { + "webhook": {"state": "fatal"}, + "api_server": {"state": "disconnected"}, + "msgraph_webhook": {"state": "connected"}, + } + } + assert _profile_platform_ports(tmp_path, runtime) == {"msgraph_webhook": 8646} + + def test_invalid_port_value_falls_back_to_default(self, tmp_path): + (tmp_path / "config.yaml").write_text( + "platforms:\n webhook:\n port: notaport\n", encoding="utf-8" + ) + runtime = {"platforms": {"webhook": {"state": "connected"}}} + assert _profile_platform_ports(tmp_path, runtime) == {"webhook": 8644} + + +# --------------------------------------------------------------------------- +# _collect_profile_gateway_topology +# --------------------------------------------------------------------------- + +def _patch_topology(monkeypatch, homes, running, runtimes): + """Patch the topology collector's collaborators. + + ``homes``: list of (name, Path); ``running``: set of profile names with a + live gateway; ``runtimes``: {name: runtime dict}. + """ + import hermes_cli.profiles as profiles_mod + import gateway.status as status_mod + + monkeypatch.setattr(profiles_mod, "profiles_to_serve", lambda multiplex: homes) + monkeypatch.setattr( + profiles_mod, "_check_gateway_running", + lambda home: next(n for n, h in homes if h == home) in running, + ) + by_path = {home / "gateway_state.json": runtimes.get(name) for name, home in homes} + monkeypatch.setattr( + status_mod, "read_runtime_status", lambda path=None: by_path.get(path) + ) + + +class TestCollectProfileGatewayTopology: + def test_no_gateways_running(self, tmp_path, monkeypatch): + homes = [("default", tmp_path / "d"), ("coder", tmp_path / "c")] + _patch_topology(monkeypatch, homes, running=set(), runtimes={}) + topo = _collect_profile_gateway_topology() + assert topo["profiles"] == ["default", "coder"] + assert topo["gateway_mode"] == "none" + assert topo["gateways"] == [] + + def test_single_gateway(self, tmp_path, monkeypatch): + homes = [("default", tmp_path / "d"), ("coder", tmp_path / "c")] + _patch_topology( + monkeypatch, homes, running={"default"}, + runtimes={"default": {"platforms": {}}}, + ) + topo = _collect_profile_gateway_topology() + assert topo["gateway_mode"] == "single" + assert [g["profile"] for g in topo["gateways"]] == ["default"] + + def test_multiplex_gateway(self, tmp_path, monkeypatch): + homes = [("default", tmp_path / "d"), ("coder", tmp_path / "c")] + _patch_topology( + monkeypatch, homes, running={"default"}, + runtimes={"default": { + "platforms": {}, + "served_profiles": ["default", "coder"], + }}, + ) + topo = _collect_profile_gateway_topology() + assert topo["gateway_mode"] == "multiplex" + assert topo["gateways"][0]["served_profiles"] == ["default", "coder"] + + def test_multiple_independent_gateways_with_ports(self, tmp_path, monkeypatch): + d_home = tmp_path / "d" + c_home = tmp_path / "c" + d_home.mkdir() + c_home.mkdir() + (c_home / "config.yaml").write_text( + "platforms:\n webhook:\n port: 9644\n", encoding="utf-8" + ) + homes = [("default", d_home), ("coder", c_home)] + _patch_topology( + monkeypatch, homes, running={"default", "coder"}, + runtimes={ + "default": {"platforms": {"webhook": {"state": "connected"}}}, + "coder": {"platforms": {"webhook": {"state": "connected"}}}, + }, + ) + topo = _collect_profile_gateway_topology() + assert topo["gateway_mode"] == "multiple" + ports = {g["profile"]: g["ports"] for g in topo["gateways"]} + assert ports == {"default": {"webhook": 8644}, "coder": {"webhook": 9644}} + + def test_enumeration_failure_degrades_gracefully(self, monkeypatch): + import hermes_cli.profiles as profiles_mod + + def _boom(multiplex): + raise RuntimeError("no profiles root") + + monkeypatch.setattr(profiles_mod, "profiles_to_serve", _boom) + topo = _collect_profile_gateway_topology() + assert topo == {"profiles": [], "gateway_mode": "unknown", "gateways": []} + + +# --------------------------------------------------------------------------- +# /api/status wiring +# --------------------------------------------------------------------------- + +class TestStatusEndpointTopology: + @pytest.fixture(autouse=True) + def _setup_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.client = TestClient(app) + self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN + + def test_status_includes_topology_on_loopback(self, monkeypatch): + monkeypatch.setattr( + web_server, "_collect_profile_gateway_topology", + lambda: { + "profiles": ["default", "coder"], + "gateway_mode": "single", + "gateways": [{"profile": "default", "ports": {}}], + }, + ) + resp = self.client.get("/api/status") + assert resp.status_code == 200 + data = resp.json() + assert data["profiles"] == ["default", "coder"] + assert data["gateway_mode"] == "single" + assert data["gateways"] == [{"profile": "default", "ports": {}}] + + def test_status_omits_topology_when_auth_gated(self, monkeypatch): + monkeypatch.setattr(web_server.app.state, "auth_required", True, raising=False) + resp = self.client.get("/api/status") + assert resp.status_code == 200 + data = resp.json() + # Topology is deployment recon — hidden on gated binds, like + # hermes_home / gateway_pid. + assert "profiles" not in data + assert "gateway_mode" not in data + assert "gateways" not in data + monkeypatch.setattr(web_server.app.state, "auth_required", False, raising=False) From 8a726e91ba2b56974498cc731cc8b51c95af3b80 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:11:33 +0200 Subject: [PATCH 172/610] fix(tools): enable platform-native toolsets when their composite is explicitly configured (#35527) When a user explicitly configures a platform with its native composite (e.g. platform_toolsets.discord: [hermes-discord]), the discord and discord_admin toolsets were silently stripped by _DEFAULT_OFF_TOOLSETS even though the composite contains those tools. The strip could not tell an explicit composite opt-in apart from the unconfigured default. Track whether the platform was explicitly configured and, when it was, exempt toolsets that are both default-off and platform-restricted to the current platform from the strip. Only discord/discord_admin are affected (the sole entries in both _DEFAULT_OFF_TOOLSETS and _TOOLSET_PLATFORM_RESTRICTIONS). Unconfigured and empty-list platforms keep the security default-off behaviour. --- hermes_cli/tools_config.py | 33 ++++++++++++ tests/hermes_cli/test_tools_config.py | 74 +++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index f91abb33f8a4..ee3762741772 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1612,6 +1612,28 @@ def enabled_mcp_server_names(config: dict) -> Set[str]: } +def _exempt_explicit_platform_native( + default_off: Set[str], platform: str, *, explicitly_configured: bool +) -> None: + """Let platform-native default-off toolsets through on explicit config. + + Toolsets that are both in ``_DEFAULT_OFF_TOOLSETS`` and restricted to + ``platform`` via ``_TOOLSET_PLATFORM_RESTRICTIONS`` (currently + ``discord``/``discord_admin`` on the discord platform) are the platform's + own native tools. They are kept off for *unconfigured* platforms (security + opt-in), but once a user explicitly saves a toolset list for the platform + the composite they chose (e.g. ``hermes-discord``, which contains those + tools) is an opt-in — stripping them silently defeats the explicit + configuration (#35527). Mutates ``default_off`` in place. + """ + if not explicitly_configured: + return + for ts in list(default_off): + allowed = _TOOLSET_PLATFORM_RESTRICTIONS.get(ts) + if allowed is not None and platform in allowed: + default_off.discard(ts) + + def _get_platform_tools( config: dict, platform: str, @@ -1623,6 +1645,11 @@ def _get_platform_tools( platform_toolsets = config.get("platform_toolsets") or {} toolset_names = platform_toolsets.get(platform) + # Track whether the user explicitly saved a toolset list for this platform + # (vs. falling back to the platform default). An explicit composite (e.g. + # ``hermes-discord``) is an opt-in to the platform's native default-off + # toolsets — see _exempt_explicit_platform_native (#35527). + explicitly_configured = isinstance(toolset_names, list) if toolset_names is None or not isinstance(toolset_names, list): plat_info = PLATFORMS.get(platform) @@ -1686,6 +1713,9 @@ def _get_platform_tools( default_off.remove(platform) if "homeassistant" in default_off and os.getenv("HASS_TOKEN"): default_off.remove("homeassistant") + _exempt_explicit_platform_native( + default_off, platform, explicitly_configured=explicitly_configured + ) expanded -= default_off enabled_toolsets |= expanded @@ -1748,6 +1778,9 @@ def _get_platform_tools( # strip the entry we just added. if x_search_auto_enabled and "x_search" in default_off: default_off.remove("x_search") + _exempt_explicit_platform_native( + default_off, platform, explicitly_configured=explicitly_configured + ) enabled_toolsets -= default_off # Recover non-configurable platform toolsets (e.g. discord, feishu_doc, diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index dbe95a0cb5d5..8a14fba3c6fd 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -243,6 +243,80 @@ def test_get_platform_tools_x_search_auto_enabled_when_xai_oauth_present(monkeyp assert "x_search" in enabled, f"x_search missing for {plat}" +# ─── #35527: platform-restricted default-off toolsets (discord/discord_admin) +# are stripped by _DEFAULT_OFF_TOOLSETS even when the user explicitly opts in +# via the platform's native composite. The composite ``hermes-discord`` +# contains both ``discord`` and ``discord_admin`` tools, so configuring it is +# an explicit opt-in that should survive the default-off strip. ─────────────── + + +def test_discord_composite_only_enables_discord_toolsets(): + """Layer 1: ``platform_toolsets.discord: [hermes-discord]`` is an explicit + opt-in to the full Discord bundle (which includes the ``discord`` and + ``discord_admin`` tools). They must not be silently stripped.""" + config = {"platform_toolsets": {"discord": ["hermes-discord"]}} + enabled = _get_platform_tools(config, "discord") + assert "discord" in enabled, "discord toolset missing from hermes-discord composite" + assert "discord_admin" in enabled, "discord_admin toolset missing from composite" + + +def test_discord_composite_plus_configurable_enables_discord_toolsets(): + """Layer 2: mixing the composite with a configurable key (e.g. spotify) + still opts into the Discord toolsets carried by the composite.""" + config = {"platform_toolsets": {"discord": ["hermes-discord", "spotify"]}} + enabled = _get_platform_tools(config, "discord") + assert "discord" in enabled + assert "discord_admin" in enabled + + +def test_discord_composite_plus_partial_explicit_enables_sibling(): + """Layer 3: ``[hermes-discord, discord]`` lists discord explicitly but + discord_admin arrives only via the composite. Both must survive.""" + config = {"platform_toolsets": {"discord": ["hermes-discord", "discord"]}} + enabled = _get_platform_tools(config, "discord") + assert "discord" in enabled + assert "discord_admin" in enabled + + +def test_discord_unconfigured_keeps_discord_toolsets_off(): + """Layer 4 (guard): an unconfigured discord platform keeps the platform + toolsets OFF by default — explicit configuration is required to opt in.""" + enabled = _get_platform_tools({}, "discord") + assert "discord" not in enabled + assert "discord_admin" not in enabled + + +def test_discord_empty_list_keeps_discord_toolsets_off(): + """Layer 4 (guard): an explicit empty list means 'nothing' — the Discord + toolsets must not be auto-added even though the fix keys off explicit + configuration.""" + config = {"platform_toolsets": {"discord": []}} + enabled = _get_platform_tools(config, "discord") + assert "discord" not in enabled + assert "discord_admin" not in enabled + + +def test_discord_toolsets_do_not_leak_to_other_platforms(): + """Layer 4 (guard): discord/discord_admin are platform-restricted — they + must never appear on a non-discord platform even when that platform is + explicitly configured.""" + config = {"platform_toolsets": {"telegram": ["hermes-telegram", "discord"]}} + enabled = _get_platform_tools(config, "telegram") + assert "discord" not in enabled + assert "discord_admin" not in enabled + + +def test_discord_explicit_workaround_still_works(): + """Regression guard: the documented workaround of listing toolsets + explicitly must keep working after the fix.""" + config = { + "platform_toolsets": {"discord": ["hermes-discord", "discord", "discord_admin"]} + } + enabled = _get_platform_tools(config, "discord") + assert "discord" in enabled + assert "discord_admin" in enabled + + def test_get_platform_tools_x_search_auto_enabled_when_xai_api_key_present(monkeypatch): """x_search toolset auto-enables when XAI_API_KEY is set, even without OAuth tokens — the API-key path is a supported credential source.""" From 551f00109de824f147e0826260f649b3fdc3f9e7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:29:27 -0700 Subject: [PATCH 173/610] docs(sessions): unify export docs under one overview section (#60554) Restructures the five parallel export sections into a single 'Export Sessions' section: a format table (jsonl/md/qmd/html/trace + --only user-prompts), one shared-filters paragraph covering all formats, and per-format subsections nested beneath. EN + zh-Hans. --- website/docs/user-guide/sessions.md | 25 ++++++++++++++----- .../current/user-guide/sessions.md | 25 ++++++++++++++----- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index 96a0c6484925..daece81f4829 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -294,6 +294,21 @@ What's the weather in Las Vegas? 3d ago tele 2025030 ### Export Sessions +`hermes sessions export` is one surface for every export format, selected with `--format`: + +| Format | Output | Use it for | +|--------|--------|------------| +| `jsonl` (default) | one JSON object per session | backups, machine round-trip | +| `md` / `qmd` | one Markdown/Quarto file per session + manifest | readable archives, notes | +| `html` | single self-contained page (sidebar for multi-session) | sharing, browsing | +| `trace` | Claude Code JSONL | HF Agent Trace Viewer, `--upload` | + +Plus `--only user-prompts` for a prompts-only view (jsonl or md). + +All formats share the same selection knobs: `--session-id` for one session, or the full `prune`/`archive` filter set for bulk — `--older-than` / `--newer-than` / `--before` / `--after` (durations like `5h`/`2d`/`1w`, bare days, or ISO timestamps), `--source`, `--title`, `--model`, `--provider`, `--cwd`, `--min/--max-messages`, `--min/--max-tokens`, `--min/--max-cost`, `--min/--max-tool-calls`, `--user`, `--chat-id`, `--chat-type`, `--branch`, `--end-reason`. `--dry-run` previews the match set without writing. `--redact` scrubs secrets (API keys, tokens, credentials) from exported content on any format — recommended for anything you plan to share. Note: bulk filters match *ended* sessions; unfiltered `export` dumps everything, including active ones. + +#### JSONL (default) + ```bash # Export all sessions to a JSONL file hermes sessions export backup.jsonl @@ -310,9 +325,7 @@ hermes sessions export backup.jsonl --redact Exported files contain one JSON object per line with full session metadata and all messages. -`export` accepts the same filters as `prune` / `archive` — `--older-than` / `--newer-than` / `--before` / `--after` (durations like `5h`/`2d`/`1w`, bare days, or ISO timestamps), `--source`, `--title`, `--model`, `--provider`, `--cwd`, `--min-messages` / `--max-messages`, `--min-tokens` / `--max-tokens`, `--min-cost` / `--max-cost`, `--min-tool-calls` / `--max-tool-calls`, `--user`, `--chat-id`, `--chat-type`, `--branch`, and `--end-reason`. Add `--dry-run` to preview which sessions match without writing anything. Note: bulk filters match *ended* sessions; unfiltered `export` dumps everything, including active ones. - -### Export Sessions to HTML +#### HTML `--format html` writes a single self-contained HTML file — no remote dependencies — with styled message bubbles, collapsible tool output, and (for multi-session exports) a sidebar to switch between sessions: @@ -324,7 +337,7 @@ hermes sessions export --format html --session-id 20250305_091523_a1b2c3d4 trans hermes sessions export --format html --newer-than 1w --source telegram --redact archive.html ``` -### Export Only Your Prompts +#### Prompts Only `--only user-prompts` exports just the prompts you wrote — no assistant replies, tool output, or system context. Useful for building prompt libraries or reviewing what you asked: @@ -338,7 +351,7 @@ hermes sessions export - --session-id 20250305_091523_a1b2c3d4 --only user-promp Works with `--format jsonl` (default) or `md`, honors the same filters for bulk export, and combines with `--redact`. -### Export Traces to the HF Agent Trace Viewer +#### Traces (HF Agent Trace Viewer) `--format trace` emits Claude Code JSONL — the transcript shape the Hugging Face Hub auto-detects for its [Agent Trace Viewer](https://huggingface.co/docs/hub/agent-traces). Write it locally, or add `--upload` to push it to your own private `hermes-traces` dataset (reads `HF_TOKEN`): @@ -355,7 +368,7 @@ hermes sessions export --format trace --session-id 20250305_091523_a1b2c3d4 --up Trace exports are secret-redacted by default (they're meant to leave the machine); `--no-redact` opts out after manual review. `--upload` is private unless `--public`. Bulk trace export with filters writes one `.trace.jsonl` per session. -### Export Sessions to Markdown/QMD +#### Markdown / QMD Pass `--format md` or `--format qmd` when you want a readable, file-based archive before hiding or deleting old sessions. Markdown/QMD exports write one file per session into a directory (default: `~/.hermes/session-exports`). diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md index 620317d3fd18..3bc6692de231 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md @@ -271,6 +271,21 @@ What's the weather in Las Vegas? 3d ago tele 2025030 ### 导出 Session +`hermes sessions export` 是所有导出格式的统一入口,用 `--format` 选择: + +| 格式 | 输出 | 适用场景 | +|------|------|----------| +| `jsonl`(默认) | 每个 session 一个 JSON 对象 | 备份、机器可读的往返格式 | +| `md` / `qmd` | 每个 session 一个 Markdown/Quarto 文件 + manifest | 可读归档、笔记 | +| `html` | 单个独立页面(多 session 带侧边栏) | 分享、浏览 | +| `trace` | Claude Code JSONL | HF Agent Trace Viewer、`--upload` | + +另有 `--only user-prompts` 只导出你的 prompt(jsonl 或 md)。 + +所有格式共享同一套选择方式:`--session-id` 导出单个 session,或使用与 `prune` / `archive` 相同的完整过滤器进行批量导出 — `--older-than` / `--newer-than` / `--before` / `--after`(时长如 `5h`/`2d`/`1w`、纯数字天数或 ISO 时间戳)、`--source`、`--title`、`--model`、`--provider`、`--cwd`、`--min/--max-messages`、`--min/--max-tokens`、`--min/--max-cost`、`--min/--max-tool-calls`、`--user`、`--chat-id`、`--chat-type`、`--branch`、`--end-reason`。`--dry-run` 可预览匹配集而不写入。`--redact` 在任意格式下从导出内容中清除密钥(API key、token、凭据)— 任何打算分享的导出都建议加上。注意:带过滤器的批量导出只匹配*已结束*的 session;不带过滤器的 `export` 会导出所有 session(包括活跃的)。 + +#### JSONL(默认) + ```bash # 将所有 session 导出到 JSONL 文件 hermes sessions export backup.jsonl @@ -287,9 +302,7 @@ hermes sessions export backup.jsonl --redact 导出文件每行包含一个 JSON 对象,包含完整的 session 元数据和所有消息。 -`export` 接受与 `prune` / `archive` 相同的过滤器 — `--older-than` / `--newer-than` / `--before` / `--after`(时长如 `5h`/`2d`/`1w`、纯数字天数或 ISO 时间戳)、`--source`、`--title`、`--model`、`--provider`、`--cwd`、`--min-messages` / `--max-messages`、`--min-tokens` / `--max-tokens`、`--min-cost` / `--max-cost`、`--min-tool-calls` / `--max-tool-calls`、`--user`、`--chat-id`、`--chat-type`、`--branch` 和 `--end-reason`。加 `--dry-run` 可预览匹配的 session 而不写入任何内容。注意:带过滤器的批量导出只匹配*已结束*的 session;不带过滤器的 `export` 会导出所有 session(包括活跃的)。 - -### 导出 Session 为 HTML +#### HTML `--format html` 生成一个完全独立的 HTML 文件 — 无远程依赖 — 带样式化的消息气泡、可折叠的工具输出,多 session 导出时还带侧边栏导航: @@ -301,7 +314,7 @@ hermes sessions export --format html --session-id 20250305_091523_a1b2c3d4 trans hermes sessions export --format html --newer-than 1w --source telegram --redact archive.html ``` -### 只导出你的 Prompt +#### 只导出 Prompt `--only user-prompts` 只导出你写的 prompt — 不含助手回复、工具输出或系统上下文。适合构建 prompt 库或回顾你问过什么: @@ -315,7 +328,7 @@ hermes sessions export - --session-id 20250305_091523_a1b2c3d4 --only user-promp 支持 `--format jsonl`(默认)或 `md`,批量导出时同样支持全部过滤器,也可与 `--redact` 组合。 -### 导出 Trace 到 HF Agent Trace Viewer +#### Trace(HF Agent Trace Viewer) `--format trace` 生成 Claude Code JSONL — Hugging Face Hub 的 [Agent Trace Viewer](https://huggingface.co/docs/hub/agent-traces) 可自动识别的转录格式。可以写入本地文件,或加 `--upload` 推送到你自己的私有 `hermes-traces` 数据集(读取 `HF_TOKEN`): @@ -332,7 +345,7 @@ hermes sessions export --format trace --session-id 20250305_091523_a1b2c3d4 --up Trace 导出默认强制脱敏(它们本来就是要离开本机的);`--no-redact` 需人工审查后才建议使用。`--upload` 默认私有,除非加 `--public`。带过滤器的批量 trace 导出会为每个 session 写一个 `.trace.jsonl`。 -### 导出 Session 为 Markdown/QMD +#### Markdown / QMD 当你想在隐藏或删除旧 session 之前保留一份可读的文件归档时,传入 `--format md` 或 `--format qmd`。Markdown/QMD 导出会为每个 session 写入一个文件到目录中(默认:`~/.hermes/session-exports`)。 From c3808cfc1409e20e2ee0422f1428436465bbda6d Mon Sep 17 00:00:00 2001 From: BROCCOLO1D <279959838+BROCCOLO1D@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:14:00 +1000 Subject: [PATCH 174/610] fix(discord): honor pairing grants for message auth --- plugins/platforms/discord/adapter.py | 20 ++++++++++++++ tests/gateway/test_discord_slash_auth.py | 33 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 8a83f8805ed9..d9f03b6fbba0 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -3227,6 +3227,18 @@ class DiscordAdapter(BasePlatformAdapter): return True return bool(channel_ids & allowed) + def _is_pairing_approved_user(self, user_id: str) -> bool: + """True when the Discord user has an explicit Hermes pairing grant.""" + user_id = str(user_id or "").strip() + if not user_id: + return False + try: + from gateway.pairing import PairingStore + + return bool(PairingStore().is_approved("discord", user_id)) + except Exception: + return False + def _is_allowed_user( self, user_id: str, @@ -3267,6 +3279,14 @@ class DiscordAdapter(BasePlatformAdapter): allowed_roles = getattr(self, "_allowed_role_ids", set()) has_users = bool(allowed_users) has_roles = bool(allowed_roles) + + # Pairing is a first-class auth grant in the gateway auth union and in + # Discord component buttons. Honor it here too so normal guild/DM text + # messages do not get dropped at the adapter before the pairing-aware + # gateway layer can see them. + if self._is_pairing_approved_user(user_id): + return True + if not has_users and not has_roles: if os.getenv("DISCORD_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}: return True diff --git a/tests/gateway/test_discord_slash_auth.py b/tests/gateway/test_discord_slash_auth.py index f9bb7f40ca81..a8d7e21e0a8d 100644 --- a/tests/gateway/test_discord_slash_auth.py +++ b/tests/gateway/test_discord_slash_auth.py @@ -178,6 +178,18 @@ def _make_interaction( ) +def _stub_pairing_store(monkeypatch, approved_ids): + approved = {str(uid) for uid in approved_ids} + + class _FakePairingStore: + def is_approved(self, platform, user_id): + return platform == "discord" and str(user_id) in approved + + import gateway.pairing as pairing + + monkeypatch.setattr(pairing, "PairingStore", _FakePairingStore) + + # --------------------------------------------------------------------------- # Backwards-compat: empty allowlist → everything passes (matches on_message) # --------------------------------------------------------------------------- @@ -235,6 +247,27 @@ async def test_disallowed_user_rejected_with_ephemeral(adapter, caplog): assert any("DISCORD_ALLOWED_USERS" in r.message for r in caplog.records) +def test_pairing_approved_user_passes_message_gate_without_allowlist(adapter, monkeypatch): + """Pairing grants must be honored before on_message drops guild mentions.""" + _stub_pairing_store(monkeypatch, {"100200300"}) + assert adapter._is_allowed_user( + "100200300", + author=SimpleNamespace(id=100200300), + guild=SimpleNamespace(id=42, get_member=lambda _uid: None), + is_dm=False, + channel_ids={"12345"}, + ) is True + + +@pytest.mark.asyncio +async def test_pairing_approved_user_passes_slash_gate_without_allowlist(adapter, monkeypatch): + """Slash and normal text auth share the pairing-aware user gate.""" + _stub_pairing_store(monkeypatch, {"100200300"}) + interaction = _make_interaction("100200300") + assert await adapter._check_slash_authorization(interaction, "/help") is True + interaction.response.send_message.assert_not_awaited() + + # --------------------------------------------------------------------------- # Role allowlist (DISCORD_ALLOWED_ROLES) parity # --------------------------------------------------------------------------- From 3e7ade418d030d241bda4c6352ff52409f4582ec Mon Sep 17 00:00:00 2001 From: yungchentang <46495124+yungchentang@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:42:47 -0700 Subject: [PATCH 175/610] fix(discord): explain fail-closed allowlist default Log a one-shot structured warning when Discord denies traffic because no allowlist/policy is configured, and correct the setup wizard's inverted warning text. The fail-closed default itself is unchanged. Fixes #58682. --- plugins/platforms/discord/adapter.py | 36 +++++++- .../test_discord_fail_closed_feedback.py | 92 +++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 tests/gateway/test_discord_fail_closed_feedback.py diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index d9f03b6fbba0..ddaac7ab074a 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -900,6 +900,7 @@ class DiscordAdapter(BasePlatformAdapter): # rate limit (~1 edit per stream tick for the rest of a long reply). # Mirrors the Telegram #58563 fix. Entries are dropped on finalize. self._last_overflow_preview: Dict[tuple, str] = {} + self._warned_fail_closed_default = False def _handle_bot_task_done(self, task: asyncio.Task) -> None: """Surface post-startup discord.py task exits to the gateway supervisor. @@ -1160,6 +1161,7 @@ class DiscordAdapter(BasePlatformAdapter): is_dm=_is_dm, channel_ids=_msg_channel_ids, ): + self._warn_if_fail_closed_default() return _role_authorized = bool(getattr(self, "_allowed_role_ids", set())) @@ -3355,6 +3357,28 @@ class DiscordAdapter(BasePlatformAdapter): m_roles = getattr(m, "roles", None) or [] return any(getattr(r, "id", None) in allowed_roles for r in m_roles) + def _warn_if_fail_closed_default(self) -> None: + """Log once when Discord is rejecting traffic with no allowlist set.""" + if getattr(self, "_warned_fail_closed_default", False): + return + allowed_users = getattr(self, "_allowed_user_ids", set()) or set() + allowed_roles = getattr(self, "_allowed_role_ids", set()) or set() + if allowed_users or allowed_roles: + return + if os.getenv("DISCORD_ALLOWED_CHANNELS", "").strip(): + return + if os.getenv("DISCORD_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}: + return + if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}: + return + self._warned_fail_closed_default = True + logger.warning( + "[%s] Discord messages are being denied because no allowlist is configured. " + "Set DISCORD_ALLOWED_USERS, DISCORD_ALLOWED_ROLES, or " + "DISCORD_ALLOWED_CHANNELS, or set DISCORD_ALLOW_ALL_USERS=true for open access.", + self.name, + ) + # ── Slash command authorization ───────────────────────────────────── # Slash commands (``_run_simple_slash`` and ``_handle_thread_create_slash``) # are a separate Discord interaction surface from regular messages and @@ -8121,7 +8145,11 @@ def interactive_setup() -> None: print_info("Discord: already configured") if not prompt_yes_no("Reconfigure Discord?", False): if not get_env_value("DISCORD_ALLOWED_USERS"): - print_info("⚠️ Discord has no user allowlist - anyone can use your bot!") + print_info( + "⚠️ Discord has no user allowlist. With the fail-closed default, " + "messages are denied unless you configure allowed users, roles, " + "or channels, or set DISCORD_ALLOW_ALL_USERS=true." + ) if prompt_yes_no("Add allowed users now?", True): print_info(" To find Discord ID: Enable Developer Mode, right-click name → Copy ID") allowed_users = prompt("Allowed user IDs (comma-separated)") @@ -8154,7 +8182,11 @@ def interactive_setup() -> None: save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids)) print_success("Discord allowlist configured") else: - print_info("⚠️ No allowlist set - anyone in servers with your bot can use it!") + print_info( + "⚠️ No allowlist set. Discord will deny messages until you set " + "DISCORD_ALLOWED_USERS, DISCORD_ALLOWED_ROLES, DISCORD_ALLOWED_CHANNELS, " + "or DISCORD_ALLOW_ALL_USERS=true for open access." + ) print() print_info("📬 Home Channel: where Hermes delivers cron job results,") diff --git a/tests/gateway/test_discord_fail_closed_feedback.py b/tests/gateway/test_discord_fail_closed_feedback.py new file mode 100644 index 000000000000..5a45e4c44236 --- /dev/null +++ b/tests/gateway/test_discord_fail_closed_feedback.py @@ -0,0 +1,92 @@ +import logging + +from gateway.config import PlatformConfig +from plugins.platforms.discord.adapter import DiscordAdapter, interactive_setup + + +def _make_adapter() -> DiscordAdapter: + return DiscordAdapter(PlatformConfig(enabled=True, token="***")) + + +def test_discord_fail_closed_default_logs_once(monkeypatch, caplog): + adapter = _make_adapter() + adapter._allowed_user_ids = set() + adapter._allowed_role_ids = set() + + for var in ( + "DISCORD_ALLOWED_CHANNELS", + "DISCORD_ALLOW_ALL_USERS", + "GATEWAY_ALLOW_ALL_USERS", + ): + monkeypatch.delenv(var, raising=False) + + with caplog.at_level(logging.WARNING): + adapter._warn_if_fail_closed_default() + adapter._warn_if_fail_closed_default() + + messages = [record.message for record in caplog.records] + matches = [ + msg for msg in messages + if "Discord messages are being denied because no allowlist is configured" in msg + ] + assert len(matches) == 1 + assert "DISCORD_ALLOWED_USERS" in matches[0] + assert "DISCORD_ALLOW_ALL_USERS=true" in matches[0] + + +def test_discord_fail_closed_default_warning_skips_explicit_channel_gate(monkeypatch, caplog): + adapter = _make_adapter() + adapter._allowed_user_ids = set() + adapter._allowed_role_ids = set() + monkeypatch.setenv("DISCORD_ALLOWED_CHANNELS", "12345") + monkeypatch.delenv("DISCORD_ALLOW_ALL_USERS", raising=False) + monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False) + + with caplog.at_level(logging.WARNING): + adapter._warn_if_fail_closed_default() + + assert "no allowlist is configured" not in caplog.text + + +def test_discord_setup_existing_token_warns_fail_closed_not_fail_open(monkeypatch): + info_lines: list[str] = [] + yes_no_answers = iter([False, False]) + + def fake_get_env_value(key: str): + return "token" if key == "DISCORD_BOT_TOKEN" else "" + + monkeypatch.setattr("hermes_cli.config.get_env_value", fake_get_env_value) + monkeypatch.setattr("hermes_cli.config.save_env_value", lambda *_args, **_kwargs: None) + monkeypatch.setattr("hermes_cli.cli_output.print_header", lambda *_args, **_kwargs: None) + monkeypatch.setattr("hermes_cli.cli_output.print_success", lambda *_args, **_kwargs: None) + monkeypatch.setattr("hermes_cli.cli_output.print_info", lambda msg="", **_kwargs: info_lines.append(str(msg))) + monkeypatch.setattr("hermes_cli.cli_output.prompt", lambda *_args, **_kwargs: "") + monkeypatch.setattr("hermes_cli.cli_output.prompt_yes_no", lambda *_args, **_kwargs: next(yes_no_answers)) + + interactive_setup() + + joined = "\n".join(info_lines) + assert "anyone can use your bot" not in joined + assert "fail-closed default" in joined + assert "DISCORD_ALLOW_ALL_USERS=true" in joined + + +def test_discord_setup_new_token_empty_allowlist_warns_denied_until_configured(monkeypatch): + info_lines: list[str] = [] + prompts = iter(["token", "", ""]) + + monkeypatch.setattr("hermes_cli.config.get_env_value", lambda _key: "") + monkeypatch.setattr("hermes_cli.config.save_env_value", lambda *_args, **_kwargs: None) + monkeypatch.setattr("hermes_cli.cli_output.print_header", lambda *_args, **_kwargs: None) + monkeypatch.setattr("hermes_cli.cli_output.print_success", lambda *_args, **_kwargs: None) + monkeypatch.setattr("hermes_cli.cli_output.print_info", lambda msg="", **_kwargs: info_lines.append(str(msg))) + monkeypatch.setattr("hermes_cli.cli_output.prompt", lambda *_args, **_kwargs: next(prompts)) + monkeypatch.setattr("hermes_cli.cli_output.prompt_yes_no", lambda *_args, **_kwargs: False) + + interactive_setup() + + joined = "\n".join(info_lines) + assert "anyone in servers with your bot can use it" not in joined + assert "Discord will deny messages" in joined + assert "DISCORD_ALLOWED_ROLES" in joined + assert "DISCORD_ALLOW_ALL_USERS=true" in joined From db9e3e4ef96bd293ea52af72a94b60556724d3dd Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:42:47 -0700 Subject: [PATCH 176/610] docs(discord): troubleshoot silent fail-closed denials Docs portion of PR #57067: 'bot connects but never replies' section pointing at the gateway.log warning and the allowlist/policy knobs. Co-authored-by: ooovenenoso <120500656+ooovenenoso@users.noreply.github.com> --- website/docs/user-guide/messaging/discord.md | 60 +++++++++++--------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 030e69681e5e..5835430c2342 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -114,7 +114,7 @@ This is the most critical step in the entire setup. Without the correct intents On the **Bot** page, scroll down to **Privileged Gateway Intents**. You'll see three toggles: | Intent | Purpose | Required? | -|--------|---------|-----------| +|--------|---------|-----------| | **Presence Intent** | See user online/offline status | Optional | | **Server Members Intent** | Access the member list, resolve usernames | **Required** | | **Message Content Intent** | Read the text content of messages | **Required** | @@ -170,7 +170,7 @@ This method requires **Public Bot** to be set to **ON** in Step 2. If you set Pu You can construct the invite URL directly using this format: ``` -https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot+applications.commands&permissions=309237763136 +https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot+applications.commands&permissions=274878286912 ``` Replace `YOUR_APP_ID` with the Application ID from Step 1. @@ -187,7 +187,6 @@ These are the minimum permissions your bot needs: ### Recommended Additional Permissions -- **Create Public Threads** - create threads - **Send Messages in Threads** — respond in thread conversations - **Add Reactions** — react to messages for acknowledgment @@ -196,7 +195,7 @@ These are the minimum permissions your bot needs: | Level | Permissions Integer | What's Included | |-------|-------------------|-----------------| | Minimal | `117760` | View Channels, Send Messages, Read Message History, Attach Files | -| Recommended | `309237763136` | All of the above plus Embed Links, Send Messages in Threads, Add Reactions, Create Public Threads | +| Recommended | `274878286912` | All of the above plus Embed Links, Send Messages in Threads, Add Reactions | ## Step 6: Invite to Your Server @@ -272,8 +271,10 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `DISCORD_BOT_TOKEN` | **Yes** | — | Bot token from the [Discord Developer Portal](https://discord.com/developers/applications). | -| `DISCORD_ALLOWED_USERS` | **Yes** | — | Comma-separated Discord user IDs allowed to interact with the bot. Without this **or** `DISCORD_ALLOWED_ROLES`, the gateway denies all users. | +| `DISCORD_ALLOWED_USERS` | Conditional | — | Comma-separated Discord user IDs allowed to interact with the bot. Without this **or** `DISCORD_ALLOWED_ROLES`, the gateway denies all users unless `DISCORD_ALLOW_ALL_USERS=true`, `GATEWAY_ALLOW_ALL_USERS=true`, or `DISCORD_ALLOWED_CHANNELS` explicitly scopes guild access. | | `DISCORD_ALLOWED_ROLES` | No | — | Comma-separated Discord role IDs. Any member with one of these roles is authorized — OR semantics with `DISCORD_ALLOWED_USERS`. Auto-enables the **Server Members Intent** on connect. Useful when moderation teams churn: new mods get access as soon as the role is granted, no config push needed. | +| `DISCORD_ALLOW_ALL_USERS` | No | `false` | Explicit opt-in to allow every Discord user who can reach the bot. This restores the pre-0.18 open behavior for Discord only; use only for trusted/private guilds or development. | +| `GATEWAY_ALLOW_ALL_USERS` | No | `false` | Global allow-all opt-in for every gateway platform. Prefer the platform-specific `DISCORD_ALLOW_ALL_USERS` unless you intentionally want all connected platforms open. | | `DISCORD_HOME_CHANNEL` | No | — | Channel ID where the bot sends proactive messages (cron output, reminders, notifications). | | `DISCORD_HOME_CHANNEL_NAME` | No | `"Home"` | Display name for the home channel in logs and status output. | | `DISCORD_COMMAND_SYNC_POLICY` | No | `"safe"` | Controls native slash-command startup sync. `"safe"` diffs existing global commands and only updates what changed, recreating commands when Discord metadata changes cannot be applied via patch. `"bulk"` preserves the old `tree.sync()` behavior. `"off"` skips startup sync entirely. | @@ -574,26 +575,6 @@ gateway: Use `/whoami` to see the active scope, your tier (admin / user / unrestricted), and which slash commands you can run. -### Restricting exec-approval buttons to admins - -By default, any user allowed to talk to the bot — including users paired via `hermes pairing approve` — can click the **Approve / Deny** buttons on a dangerous-command prompt. This mirrors plain-chat admission and is the historical behavior. To restrict *approving dangerous commands* to admins only, set `require_admin_for_exec_approval` in the Discord platform's `extra` block: - -```yaml -gateway: - platforms: - discord: - extra: - require_admin_for_exec_approval: true # default: false - allow_admin_from: - - "123456789012345678" # only these users may click Approve/Deny -``` - -**Behavior:** - -- **Default off** — exec-approval buttons stay user-scope; any admitted user can approve. Existing installs are unaffected. -- **When on** — the clicker must pass the normal admission check **and** be listed in `allow_admin_from` (the same key the slash-command split uses). The lower-stakes component views (model picker, clarify, update prompt) stay user-scope. -- **Fails closed** — if the toggle is on but `allow_admin_from` is empty, *nobody* can approve and a warning is logged, so the misconfiguration is visible rather than silently locking you out. - ## Interactive Model Picker Send `/model` with no arguments in a Discord channel to open a dropdown-based model picker: @@ -757,9 +738,34 @@ Refreshing the directory (`/channels refresh` on platforms that expose it, or a ### Bot is online but not responding to messages -**Cause**: Message Content Intent is disabled. +**Cause**: Either Message Content Intent is disabled, or Discord auth is failing closed because no access policy is configured. -**Fix**: Go to [Developer Portal](https://discord.com/developers/applications) → your app → Bot → Privileged Gateway Intents → enable **Message Content Intent** → Save Changes. Restart the gateway. +**Fix**: + +1. Go to [Developer Portal](https://discord.com/developers/applications) → your app → Bot → Privileged Gateway Intents → enable **Message Content Intent** → Save Changes. +2. Verify that at least one Discord access policy is configured: + + ```bash + # recommended: allow specific users + DISCORD_ALLOWED_USERS=284102345871466496 + + # or allow a trusted guild/dev bot to behave like pre-0.18 Discord + DISCORD_ALLOW_ALL_USERS=true + ``` + +3. Restart the gateway: + + ```bash + hermes gateway restart + ``` + +If the gateway log says Discord is connected and REST API checks work, but every inbound message is silent, look for this warning in `~/.hermes/logs/gateway.log`: + +```text +No Discord access policy configured; inbound Discord messages will be denied by default. +``` + +Hermes 0.18 intentionally fails closed on externally reachable adapters. A Discord bot with no `DISCORD_ALLOWED_USERS`, no `DISCORD_ALLOWED_ROLES`, no `DISCORD_ALLOWED_CHANNELS`, and no explicit allow-all flag will connect successfully but deny inbound users before normal message handling. ### "Disallowed Intents" error on startup From 6ca3d701fcd9ae3ed682cb8a654dc2605a09fc48 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:04:32 -0700 Subject: [PATCH 177/610] fix(gateway): only session-discover channel targets for connected platforms (#60574) Session-based channel discovery resurrected historical origins for platforms with no connected adapter, exposing stale send_message targets that can no longer deliver. Gate both the enum loop and the plugin-registry loop on the live adapter set. Surgical reapply of the channel-directory portion of PR #25959 (branch was 6.5k commits stale; the text-batching delay changes bundled there were dropped - separate concern, defaults have since been retuned on main). Co-authored-by: Marco-Olivier Lavoie --- gateway/channel_directory.py | 23 ++++++++-- .../test_channel_directory_connected_only.py | 46 +++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 tests/gateway/test_channel_directory_connected_only.py diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 16165d5e3228..0a9a2efddf84 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -128,21 +128,34 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: logger.warning("Channel directory: failed to build %s: %s", platform.value, e) # Platforms that don't support direct channel enumeration get session-based - # discovery automatically. Skip infrastructure entries that aren't messaging - # platforms — everything else falls through to _build_from_sessions(). + # discovery automatically, but only for platforms connected in THIS gateway + # process. Historical session origins for disabled/decommissioned platforms + # must not be resurrected into the active send-target directory (stale + # targets make send_message route to platforms that can no longer deliver). _SKIP_SESSION_DISCOVERY = frozenset({"local", "api_server", "webhook"}) + adapter_platform_names = {getattr(p, "value", str(p)) for p in adapters} for plat in Platform: plat_name = plat.value - if plat_name in _SKIP_SESSION_DISCOVERY or plat_name in platforms: + if ( + plat_name in _SKIP_SESSION_DISCOVERY + or plat_name in platforms + or plat_name not in adapter_platform_names + ): continue platforms[plat_name] = _build_from_sessions(plat_name) # Include plugin-registered platforms (dynamic enum members aren't in - # Platform.__members__, so the loop above misses them). + # Platform.__members__, so the loop above misses them). Same + # connected-only rule: don't expose stale session targets for plugins + # that are not loaded. try: from gateway.platform_registry import platform_registry for entry in platform_registry.plugin_entries(): - if entry.name not in _SKIP_SESSION_DISCOVERY and entry.name not in platforms: + if ( + entry.name not in _SKIP_SESSION_DISCOVERY + and entry.name not in platforms + and entry.name in adapter_platform_names + ): platforms[entry.name] = _build_from_sessions(entry.name) except Exception: pass diff --git a/tests/gateway/test_channel_directory_connected_only.py b/tests/gateway/test_channel_directory_connected_only.py new file mode 100644 index 000000000000..45da8711bf42 --- /dev/null +++ b/tests/gateway/test_channel_directory_connected_only.py @@ -0,0 +1,46 @@ +"""Session-based channel discovery must not resurrect disconnected platforms. + +Surgical reapply of the directory portion of PR #25959: historical session +origins for platforms with no connected adapter must not become active +send_message targets.""" + +import asyncio +from unittest.mock import patch + +from gateway.channel_directory import build_channel_directory +from gateway.platforms.base import Platform + + +def test_does_not_resurrect_disconnected_platforms_from_session_history(tmp_path, monkeypatch): + cache_file = tmp_path / "channel_directory.json" + + calls = [] + + def fake_build_from_sessions(plat_name): + calls.append(plat_name) + return {"channels": [{"id": "1", "name": "old"}]} + + with patch("gateway.channel_directory._build_from_sessions", side_effect=fake_build_from_sessions), \ + patch("gateway.channel_directory.DIRECTORY_PATH", cache_file): + # Only telegram is connected; no discord/slack/whatsapp adapters. + directory = asyncio.run(build_channel_directory({Platform.TELEGRAM: object()})) + + plats = directory["platforms"] + assert "telegram" in plats + # Disconnected platforms must not appear via session discovery. + for stale in ("whatsapp", "signal", "matrix"): + assert stale not in plats, f"{stale} resurrected from session history" + assert set(calls) <= {"telegram"} + + +def test_connected_platform_still_uses_session_discovery(tmp_path): + cache_file = tmp_path / "channel_directory.json" + + with patch( + "gateway.channel_directory._build_from_sessions", + return_value={"channels": []}, + ) as mock_sessions, patch("gateway.channel_directory.DIRECTORY_PATH", cache_file): + directory = asyncio.run(build_channel_directory({Platform.TELEGRAM: object()})) + + assert "telegram" in directory["platforms"] + mock_sessions.assert_any_call("telegram") From 0263f1d12e8b4671b53c405dd9b29d58fcc67677 Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Tue, 7 Jul 2026 14:11:47 +1000 Subject: [PATCH 178/610] Fix delegation config precedence --- tests/tools/test_delegate.py | 39 ++++++++++++++++++++++++++++++++++++ tools/delegate_tool.py | 30 ++++++++++++++------------- 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 5830706bf95d..5deb6c24946f 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -13,6 +13,7 @@ import json import os import threading import time +import types import unittest from unittest.mock import MagicMock, patch @@ -21,6 +22,7 @@ from tools.delegate_tool import ( DELEGATE_TASK_SCHEMA, DelegateEvent, _get_max_concurrent_children, + _load_config, _LEGACY_EVENT_MAP, MAX_DEPTH, check_delegate_requirements, @@ -2390,6 +2392,43 @@ class TestDelegateEventEnum(unittest.TestCase): class TestConcurrencyDefaults(unittest.TestCase): """Tests for the concurrency default and no hard ceiling.""" + def test_load_config_prefers_active_persistent_config_over_cli_defaults(self): + stale_cli = types.ModuleType("cli") + stale_cli.CLI_CONFIG = { + "delegation": { + "max_iterations": 45, + "model": "", + "provider": "", + "base_url": "", + "api_key": "", + } + } + active_config = { + "delegation": { + "max_iterations": 50, + "max_concurrent_children": 50, + "max_spawn_depth": 10, + } + } + + with patch.dict("sys.modules", {"cli": stale_cli}): + with patch("hermes_cli.config.load_config", return_value=active_config): + self.assertEqual(_load_config()["max_concurrent_children"], 50) + self.assertEqual(_get_max_concurrent_children(), 50) + + def test_load_config_falls_back_to_cli_config_when_persistent_load_fails(self): + fallback_cli = types.ModuleType("cli") + fallback_cli.CLI_CONFIG = { + "delegation": { + "max_iterations": 45, + "max_concurrent_children": 8, + } + } + + with patch.dict("sys.modules", {"cli": fallback_cli}): + with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")): + self.assertEqual(_load_config()["max_concurrent_children"], 8) + @patch("tools.delegate_tool._load_config", return_value={}) def test_default_is_three(self, mock_cfg): # Clear env var if set diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 3e6777498b87..58829528a695 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -3098,26 +3098,28 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: def _load_config() -> dict: - """Load delegation config from CLI_CONFIG or persistent config. + """Load delegation config from the active Hermes config. - Checks the runtime config (cli.py CLI_CONFIG) first, then falls back - to the persistent config (hermes_cli/config.py load_config()) so that - ``delegation.model`` / ``delegation.provider`` are picked up regardless - of the entry point (CLI, gateway, cron). + Prefer the shared persistent loader because it follows the active + HERMES_HOME/profile. ``cli.CLI_CONFIG`` is a legacy fallback for entry + points that cannot import the shared loader; importing it first can return + an old default ``delegation`` block and hide user-set keys such as + ``max_concurrent_children``. """ - try: - from cli import CLI_CONFIG - - cfg = CLI_CONFIG.get("delegation") or {} - if cfg: - return cfg - except Exception: - pass try: from hermes_cli.config import load_config full = load_config() - return full.get("delegation") or {} + cfg = full.get("delegation") or {} + if isinstance(cfg, dict): + return cfg + except Exception: + pass + try: + from cli import CLI_CONFIG + + cfg = CLI_CONFIG.get("delegation") or {} + return cfg if isinstance(cfg, dict) else {} except Exception: return {} From bf7639138e0292d4d44e43faed22b42423d5c358 Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Wed, 8 Jul 2026 08:57:02 +1000 Subject: [PATCH 179/610] Use read-only config loader and honor HERMES_IGNORE_USER_CONFIG in delegation config --- tests/tools/test_delegate.py | 32 ++++++++++++++++++++++++++++++-- tools/delegate_tool.py | 30 +++++++++++++++++++++--------- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 5deb6c24946f..43c8089f1127 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -2412,7 +2412,9 @@ class TestConcurrencyDefaults(unittest.TestCase): } with patch.dict("sys.modules", {"cli": stale_cli}): - with patch("hermes_cli.config.load_config", return_value=active_config): + with patch( + "hermes_cli.config.load_config_readonly", return_value=active_config + ): self.assertEqual(_load_config()["max_concurrent_children"], 50) self.assertEqual(_get_max_concurrent_children(), 50) @@ -2426,9 +2428,35 @@ class TestConcurrencyDefaults(unittest.TestCase): } with patch.dict("sys.modules", {"cli": fallback_cli}): - with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")): + with patch( + "hermes_cli.config.load_config_readonly", + side_effect=RuntimeError("boom"), + ): self.assertEqual(_load_config()["max_concurrent_children"], 8) + def test_load_config_prefers_cli_config_when_user_config_ignored(self): + # `hermes chat --ignore-user-config` sets HERMES_IGNORE_USER_CONFIG=1, + # which only load_cli_config() honors. The delegation loader must keep + # CLI_CONFIG authoritative under the flag so user config.yaml + # delegation keys stay suppressed. + ignoring_cli = types.ModuleType("cli") + ignoring_cli.CLI_CONFIG = { + "delegation": { + "max_iterations": 45, + "max_concurrent_children": 4, + } + } + user_config = {"delegation": {"max_concurrent_children": 50}} + + with patch.dict("sys.modules", {"cli": ignoring_cli}): + with patch.dict(os.environ, {"HERMES_IGNORE_USER_CONFIG": "1"}): + with patch( + "hermes_cli.config.load_config_readonly", + return_value=user_config, + ) as mock_loader: + self.assertEqual(_load_config()["max_concurrent_children"], 4) + mock_loader.assert_not_called() + @patch("tools.delegate_tool._load_config", return_value={}) def test_default_is_three(self, mock_cfg): # Clear env var if set diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 58829528a695..b989b7983b19 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -3105,16 +3105,28 @@ def _load_config() -> dict: points that cannot import the shared loader; importing it first can return an old default ``delegation`` block and hide user-set keys such as ``max_concurrent_children``. - """ - try: - from hermes_cli.config import load_config - full = load_config() - cfg = full.get("delegation") or {} - if isinstance(cfg, dict): - return cfg - except Exception: - pass + Uses ``load_config_readonly()``: every consumer of this dict is read-only + (``.get()`` lookups), and this runs on each ``get_definitions()`` schema + rebuild via ``_get_max_concurrent_children``, so skipping the defensive + deepcopy matters. Do NOT mutate the returned dict. + + ``HERMES_IGNORE_USER_CONFIG=1`` (``hermes chat --ignore-user-config``) is + only honored by the legacy ``cli`` loader, not the shared one, so when the + flag is set we keep ``cli.CLI_CONFIG`` authoritative to preserve the + flag's contract of suppressing user config.yaml settings. + """ + prefer_legacy = os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1" + if not prefer_legacy: + try: + from hermes_cli.config import load_config_readonly + + full = load_config_readonly() + cfg = full.get("delegation") or {} + if isinstance(cfg, dict): + return cfg + except Exception: + pass try: from cli import CLI_CONFIG From 5633fa19b879459119c0d19fd433c452e6ef26a4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:17:51 -0700 Subject: [PATCH 180/610] fix(dashboard): advertise truecolor to the embedded chat TUI (#60576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless/hosted deploys run the dashboard server without COLORTERM in the process environment, so chalk inside the PTY-spawned TUI child downgraded every skin hex color to the xterm 256 palette — the default skin's bronze banner border (#CD7F32) snapped to palette 173 (#D7875F, salmon red) and the gold caduceus rendered red/yellow on fresh cloud instances. Local launches never reproduced it because the operator's interactive terminal leaks COLORTERM=truecolor into the server env. xterm.js always renders 24-bit RGB, so the dashboard PTY child should always advertise truecolor: backfill COLORTERM=truecolor in _resolve_chat_argv via setdefault (an explicit operator value wins). Verified with a clean-env PTY probe of the real TUI binary: no COLORTERM -> 0 truecolor SGRs / 165 palette-256 (salmon 38;5;173); with the backfill -> 166 truecolor SGRs, exact bronze 38;2;205;127;50. --- hermes_cli/web_server.py | 11 ++++++++++ tests/hermes_cli/test_web_server.py | 33 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 4ae8c3251d2a..3a8540331954 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -13087,6 +13087,17 @@ def _resolve_chat_argv( # the dashboard PTY path. env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1") env.setdefault("HERMES_TUI_INLINE", "1") + # The dashboard terminal is xterm.js, which always renders 24-bit RGB. + # But chalk inside the TUI child decides its color depth from the + # SERVER process env — and hosted/cloud deploys run the dashboard under + # a process manager (container init, systemd) with no COLORTERM, so + # chalk downgrades every hex color to the xterm 256 palette. The skin's + # bronze border #CD7F32 snaps to palette 173 (#D7875F, salmon-red) and + # the banner reads red/yellow instead of gold. Local launches dodge + # this only because the operator's interactive terminal leaks + # COLORTERM=truecolor into os.environ. Backfill it for the PTY child; + # setdefault so an explicit operator value still wins. + env.setdefault("COLORTERM", "truecolor") env["HERMES_TUI_DASHBOARD"] = "1" if profile_dir is not None: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 18f5facbb6b6..dc1bfcccf793 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -5919,6 +5919,39 @@ class TestPtyWebSocket: assert env["HERMES_TUI_INLINE"] == "1" assert env["HERMES_TUI_DISABLE_MOUSE"] == "1" + def test_resolve_chat_argv_backfills_colorterm_truecolor(self, monkeypatch): + """Headless servers (cloud/systemd) have no COLORTERM, which made + chalk in the TUI child degrade skin hex colors to the xterm 256 + palette (gold banner rendered salmon-red). xterm.js always supports + 24-bit color, so the PTY env must advertise truecolor.""" + import hermes_cli.main as main_mod + + monkeypatch.setattr( + main_mod, + "_make_tui_argv", + lambda project_root, tui_dev=False: (["node", "dist/entry.js"], "/tmp/ui-tui"), + ) + monkeypatch.delenv("COLORTERM", raising=False) + + _argv, _cwd, env = self.ws_module._resolve_chat_argv() + + assert env["COLORTERM"] == "truecolor" + + def test_resolve_chat_argv_keeps_operator_colorterm(self, monkeypatch): + """An explicit operator COLORTERM wins over the backfill.""" + import hermes_cli.main as main_mod + + monkeypatch.setattr( + main_mod, + "_make_tui_argv", + lambda project_root, tui_dev=False: (["node", "dist/entry.js"], "/tmp/ui-tui"), + ) + monkeypatch.setenv("COLORTERM", "24bit") + + _argv, _cwd, env = self.ws_module._resolve_chat_argv() + + assert env["COLORTERM"] == "24bit" + def test_resolve_chat_argv_applies_terminal_backend_config( self, monkeypatch, _isolate_hermes_home ): From 8d66e78844f4be97b1ead2cb15f33d2076fb0ee0 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Wed, 8 Jul 2026 10:20:13 +1000 Subject: [PATCH 181/610] feat(dashboard): expose profile names + gateway_mode on gated /api/status (#60585) The profile+gateway topology added in #60537 sits entirely behind the loopback/--insecure auth gate. But a hosted agent (Hermes Cloud) binds non-loopback with OAuth, so should_require_auth is True, and NAS reads /api/status over the network (fly-provider.ts getInstanceRuntimeStatus) with no session token. On that gated path the whole topology block was omitted, so the Portal could never render the profile list. Split the topology readout by sensitivity: - profile NAMES (profiles) + gateway_mode are low-sensitivity product surface and now ride the always-public status body, surviving the auth gate so NAS/the Portal can enumerate profiles. - the per-gateway detail (gateways[], carrying host ports) is deployment recon and stays gated alongside hermes_home / config_path / env_path / gateway_pid / gateway_health_url. The collector now runs unconditionally (still in the executor, off the event loop). No new fields; only the gate placement changes. --- hermes_cli/web_server.py | 49 +++++++++++-------- .../test_web_server_gateway_topology.py | 40 ++++++++++----- 2 files changed, 58 insertions(+), 31 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 3a8540331954..74f97ec9c2ce 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2575,33 +2575,42 @@ async def get_status(profile: Optional[str] = None): "nous_session_valid": nous_session_valid, } - # Absolute host paths, the gateway PID, and the internal gateway health - # URL are deployment recon a liveness probe never needs. ``/api/status`` - # is in ``PUBLIC_API_PATHS`` so it bypasses dashboard auth; on a - # network-exposed (gated) bind that means *any* unauthenticated caller - # reaches it, and leaking host metadata there contradicts the allowlist's - # own contract ("version, gateway state, active session count, and the - # dashboard auth-gate shape. No bodies, no session content, no secrets"). - # Surface this detail only on a loopback / ``--insecure`` bind, where the - # dashboard is local-only and the caller is already inside the trust - # envelope — the same loopback/gated split ``should_require_auth`` draws. + # Profile + gateway topology: which profiles exist, whether one + # multiplexed gateway or several per-profile gateways serve them, and + # (gated) which host ports the live gateways' port-binding platforms + # listen on. Enumerating profiles walks the filesystem and probes the + # process table, so keep it off the event loop. + # + # Split by sensitivity: profile NAMES (``profiles``) and the gateway + # ``gateway_mode`` are low-sensitivity PRODUCT surface — Hermes Cloud + # renders the profile list in the Portal, which reads this endpoint over + # the network (a gated bind), so they must survive the auth gate. The + # per-gateway ``gateways[]`` detail carries host ports (deployment + # recon), so it stays gated with the host paths / PID below. + topology = await asyncio.get_running_loop().run_in_executor( + None, _collect_profile_gateway_topology + ) + status["profiles"] = topology["profiles"] + status["gateway_mode"] = topology["gateway_mode"] + + # Absolute host paths, the gateway PID, the internal gateway health + # URL, and per-gateway ports are deployment recon a liveness probe never + # needs. ``/api/status`` is in ``PUBLIC_API_PATHS`` so it bypasses + # dashboard auth; on a network-exposed (gated) bind that means *any* + # unauthenticated caller reaches it, and leaking host metadata there + # contradicts the allowlist's own contract ("version, gateway state, + # active session count, and the dashboard auth-gate shape. No bodies, no + # session content, no secrets"). Surface this detail only on a loopback + # / ``--insecure`` bind, where the dashboard is local-only and the + # caller is already inside the trust envelope — the same loopback/gated + # split ``should_require_auth`` draws. if not auth_required: - # Profile + gateway topology: which profiles exist, whether one - # multiplexed gateway or several per-profile gateways serve them, - # and which host ports the live gateways' port-binding platforms - # listen on. Enumerating profiles walks the filesystem and probes - # the process table, so keep it off the event loop. - topology = await asyncio.get_running_loop().run_in_executor( - None, _collect_profile_gateway_topology - ) status.update({ "hermes_home": str(get_hermes_home()), "config_path": str(get_config_path()), "env_path": str(get_env_path()), "gateway_pid": gateway_pid, "gateway_health_url": _GATEWAY_HEALTH_URL, - "profiles": topology["profiles"], - "gateway_mode": topology["gateway_mode"], "gateways": topology["gateways"], }) diff --git a/tests/hermes_cli/test_web_server_gateway_topology.py b/tests/hermes_cli/test_web_server_gateway_topology.py index 18de4aac72a4..457f6a16d896 100644 --- a/tests/hermes_cli/test_web_server_gateway_topology.py +++ b/tests/hermes_cli/test_web_server_gateway_topology.py @@ -191,7 +191,7 @@ class TestStatusEndpointTopology: self.client = TestClient(app) self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN - def test_status_includes_topology_on_loopback(self, monkeypatch): + def test_status_includes_full_topology_on_loopback(self, monkeypatch): monkeypatch.setattr( web_server, "_collect_profile_gateway_topology", lambda: { @@ -205,16 +205,34 @@ class TestStatusEndpointTopology: data = resp.json() assert data["profiles"] == ["default", "coder"] assert data["gateway_mode"] == "single" + # The per-gateway detail (host ports) is loopback-only recon. assert data["gateways"] == [{"profile": "default", "ports": {}}] - def test_status_omits_topology_when_auth_gated(self, monkeypatch): + def test_profile_names_and_mode_public_when_auth_gated(self, monkeypatch): + # Profile NAMES + gateway_mode are low-sensitivity product surface: the + # Hermes Cloud Portal reads /api/status over the network (a gated bind) + # to render the profile list, so they must survive the auth gate. + monkeypatch.setattr( + web_server, "_collect_profile_gateway_topology", + lambda: { + "profiles": ["default", "coder"], + "gateway_mode": "multiplex", + "gateways": [{"profile": "default", "ports": {"webhook": 8644}}], + }, + ) monkeypatch.setattr(web_server.app.state, "auth_required", True, raising=False) - resp = self.client.get("/api/status") - assert resp.status_code == 200 - data = resp.json() - # Topology is deployment recon — hidden on gated binds, like - # hermes_home / gateway_pid. - assert "profiles" not in data - assert "gateway_mode" not in data - assert "gateways" not in data - monkeypatch.setattr(web_server.app.state, "auth_required", False, raising=False) + try: + resp = self.client.get("/api/status") + assert resp.status_code == 200 + data = resp.json() + assert data["profiles"] == ["default", "coder"] + assert data["gateway_mode"] == "multiplex" + # But the per-gateway detail (host ports = recon) stays gated, + # alongside hermes_home / gateway_pid. + assert "gateways" not in data + assert "hermes_home" not in data + assert "gateway_pid" not in data + finally: + monkeypatch.setattr( + web_server.app.state, "auth_required", False, raising=False + ) From 4e4a69cbf7c3c92f8965552bc26afaa50248ee78 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Wed, 8 Jul 2026 10:20:23 +1000 Subject: [PATCH 182/610] feat(relay): carry routed profile from the connector wire source (#60586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multiplex machinery already routes an inbound message to a profile via SessionSource.profile (build_session_key namespacing + the per-turn config/credential scope in SessionStore._resolve_profile_for_key). But the relay path never populated it: _event_from_wire rebuilt the SessionSource field-by-field and dropped any 'profile' the connector sent, so a Team-Gateway (connector + relay) message could not be routed to a specific profile the way the /p// HTTP prefix and per-credential polling adapters already can. Stamp source.profile from the wire payload in _event_from_wire. This is the last missing link for NAS-driven per-profile routing over the relay in multiplex mode; the connector populating the field ships separately (gateway-gateway contract adds the optional wire field). Back-compat: absent 'profile' → None → legacy agent:main namespace, byte-identical to today for every single-profile gateway. --- gateway/relay/ws_transport.py | 9 ++++ tests/gateway/test_relay_upstream_authz.py | 48 ++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/gateway/relay/ws_transport.py b/gateway/relay/ws_transport.py index 2f79222a446d..f93c3c2cd927 100644 --- a/gateway/relay/ws_transport.py +++ b/gateway/relay/ws_transport.py @@ -120,6 +120,15 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent: scope_id=src.get("scope_id"), parent_chat_id=src.get("parent_chat_id"), message_id=src.get("message_id"), + # The HERMES profile this event is routed to (multiplex mode). The + # connector stamps it on the wire source when NAS resolves the target + # profile for a Team-Gateway message; absent for a single-profile + # gateway, where it stays None and session keys keep the legacy + # ``agent:main`` namespace (SessionStore._resolve_profile_for_key). + # Consumed by build_session_key's profile namespacing + the per-turn + # config/credential scope — the same field the /p// HTTP + # prefix and per-credential polling adapters already set. + profile=src.get("profile"), # Authentic upstream-trust signal: this event arrived over the # per-instance-authenticated relay WS, so the connector already resolved # it to this instance's owner-bound author. ``platform`` is the diff --git a/tests/gateway/test_relay_upstream_authz.py b/tests/gateway/test_relay_upstream_authz.py index 1fca02b17457..c712331124ea 100644 --- a/tests/gateway/test_relay_upstream_authz.py +++ b/tests/gateway/test_relay_upstream_authz.py @@ -243,3 +243,51 @@ def test_event_from_wire_sets_relay_delivery_marker(): ) assert event.source.platform is Platform.DISCORD assert event.source.delivered_via_upstream_relay is True + + +def test_event_from_wire_stamps_routed_profile(): + """A connector-routed profile on the wire source lands on SessionSource. + + In multiplex mode the connector resolves the target HERMES profile for a + Team-Gateway message and stamps ``profile`` on the wire source. The relay + transport must carry it through so build_session_key namespaces the session + and the agent turn resolves that profile's config/credentials. + """ + from gateway.relay.ws_transport import _event_from_wire + + event = _event_from_wire( + { + "text": "hello!", + "source": { + "platform": "discord", + "chat_id": "123", + "chat_type": "dm", + "user_id": "267171776755269633", + "user_name": "rewbs", + "profile": "reviewer", + }, + } + ) + assert event.source.profile == "reviewer" + + +def test_event_from_wire_profile_absent_is_none(): + """No ``profile`` on the wire (single-profile gateway) → None. + + Back-compat: a connector that never sets ``profile`` yields the legacy + behaviour, and session keys stay in the ``agent:main`` namespace. + """ + from gateway.relay.ws_transport import _event_from_wire + + event = _event_from_wire( + { + "text": "hi", + "source": { + "platform": "discord", + "chat_id": "123", + "chat_type": "dm", + "user_id": "1", + }, + } + ) + assert event.source.profile is None From 4b184cbe5456837a36bfec3df10db823debe023e Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Fri, 3 Jul 2026 10:32:07 +1000 Subject: [PATCH 183/610] Add dashboard memory provider switching --- hermes_cli/web_server.py | 911 +++++++++++++++++++++++----- tests/hermes_cli/test_web_server.py | 227 ++++++- web/src/lib/api.ts | 84 ++- web/src/pages/PluginsPage.tsx | 697 ++++++++++++++++++--- web/src/pages/SystemPage.tsx | 38 +- 5 files changed, 1714 insertions(+), 243 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 74f97ec9c2ce..b14b38b5a6ac 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -28,6 +28,7 @@ import mimetypes import os import re import secrets +import shlex import shutil import stat import subprocess @@ -73,11 +74,6 @@ from hermes_cli.config import ( write_platform_config_field, _deep_merge, ) -from hermes_cli.memory_providers import ( - MemoryProvider, - ProviderField, - get_memory_provider, -) from gateway.status import ( derive_gateway_busy, derive_gateway_drainable, @@ -668,11 +664,6 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "description": "Input behavior while agent is running", "options": ["interrupt", "queue", "steer"], }, - "memory.provider": { - "type": "select", - "description": "Memory provider plugin", - "options": ["builtin", "honcho"], - }, "approvals.mode": { "type": "select", "description": "Dangerous command approval mode", @@ -785,7 +776,7 @@ def _build_schema_from_config( full_key = f"{prefix}.{key}" if prefix else key # Skip internal / version keys - if full_key in {"_config_version",}: + if full_key in {"_config_version", "memory.provider"}: continue # Category is the first path component for nested keys, or "general" @@ -857,7 +848,11 @@ class EnvVarReveal(BaseModel): class MemoryProviderConfigUpdate(BaseModel): - values: Dict[str, str] = {} + values: Dict[str, Any] = {} + + +class MemoryProviderSetupRequest(BaseModel): + values: Dict[str, Any] = {} class MessagingPlatformUpdate(BaseModel): @@ -4151,151 +4146,800 @@ 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 _memory_provider_label(name: str) -> str: + return name.replace("_", " ").replace("-", " ").title() -def _read_memory_provider_file(provider: MemoryProvider) -> Dict[str, Any]: - path = _memory_provider_config_path(provider) +def _normalize_memory_provider_name(name: Any) -> str: + provider = str(name or "").strip() + if provider.lower() in {"built-in", "builtin", "none"}: + return "" + return provider + + +def _load_memory_provider(name: str): + try: + from plugins.memory import load_memory_provider + + return load_memory_provider(name) + except Exception: + _log.debug("Failed to load memory provider %s", name, exc_info=True) + return None + + +def _memory_provider_manifest(name: str) -> Dict[str, Any]: + try: + from plugins.memory import find_provider_dir + + provider_dir = find_provider_dir(name) + if provider_dir is None: + return {} + manifest_path = provider_dir / "plugin.yaml" + if not manifest_path.exists(): + return {} + with manifest_path.open(encoding="utf-8-sig") as handle: + manifest = yaml.safe_load(handle) or {} + return manifest if isinstance(manifest, dict) else {} + except Exception: + _log.debug("Failed to read memory provider manifest for %s", name, exc_info=True) + return {} + + +def _string_list(value: Any) -> List[str]: + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if str(item).strip()] + + +def _memory_provider_setup_manifest(name: str) -> Dict[str, Any]: + manifest = _memory_provider_manifest(name) + external_dependencies: List[Dict[str, str]] = [] + for raw in manifest.get("external_dependencies") or []: + if not isinstance(raw, dict): + continue + dep = { + "name": str(raw.get("name") or "").strip(), + "install": str(raw.get("install") or "").strip(), + "check": str(raw.get("check") or "").strip(), + } + if dep["name"] or dep["install"] or dep["check"]: + external_dependencies.append(dep) + + return { + "pip_dependencies": _string_list(manifest.get("pip_dependencies")), + "external_dependencies": external_dependencies, + "required_env": _string_list(manifest.get("requires_env")), + } + + +def _memory_provider_setup_info(name: str) -> Dict[str, Any]: + setup = _memory_provider_setup_manifest(name) + setup["dependencies_installed"] = _memory_provider_dependencies_installed(setup) + return setup + + +_MEMORY_PROVIDER_IMPORT_NAMES = { + "honcho-ai": "honcho", + "mem0ai": "mem0", + "hindsight-client": "hindsight_client", + "hindsight-all": "hindsight", +} + + +def _memory_provider_dependency_package(dep: str) -> str: + return re.split(r"[\[<>=!~;]", dep, maxsplit=1)[0].strip() + + +def _memory_provider_import_name(dep: str) -> str: + package = _memory_provider_dependency_package(dep) + return _MEMORY_PROVIDER_IMPORT_NAMES.get(package, package.replace("-", "_")) + + +def _dependency_importable(dep: str) -> bool: + import_name = _memory_provider_import_name(dep) + if not import_name: + return False + try: + __import__(import_name) + return True + except ImportError: + return False + + +def _trim_setup_output(value: Optional[str], limit: int = 4000) -> str: + text = str(value or "") + if len(text) <= limit: + return text + return f"{text[:limit]}\n... truncated ..." + + +def _memory_provider_setup_env() -> Dict[str, str]: + env = os.environ.copy() + home = Path.home() + extra_bins = [ + home / ".brv-cli" / "bin", + home / ".local" / "bin", + home / ".npm-global" / "bin", + Path("/usr/local/bin"), + ] + existing_path = env.get("PATH", "") + prefix = os.pathsep.join(str(path) for path in extra_bins if path.exists()) + if prefix: + env["PATH"] = prefix + os.pathsep + existing_path + return env + + +def _command_result( + *, + kind: str, + name: str, + status: str, + command: str = "", + completed: Optional[subprocess.CompletedProcess] = None, + error: Optional[str] = None, +) -> Dict[str, Any]: + return { + "kind": kind, + "name": name, + "status": status, + "command": command, + "returncode": None if completed is None else completed.returncode, + "stdout": "" if completed is None else _trim_setup_output(completed.stdout), + "stderr": _trim_setup_output(error or ("" if completed is None else completed.stderr)), + } + + +def _run_setup_command( + command: Any, + *, + display: str, + shell: bool = False, + timeout: int = 180, +) -> subprocess.CompletedProcess: + return subprocess.run( + command, + shell=shell, + executable="/bin/bash" if shell else None, + env=_memory_provider_setup_env(), + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + + +def _memory_provider_dependencies_installed(setup: Dict[str, Any]) -> bool: + pip_dependencies = _string_list(setup.get("pip_dependencies")) + external_dependencies = setup.get("external_dependencies") or [] + + pip_ok = all(_dependency_importable(dep) for dep in pip_dependencies) + external_ok = True + for dep in external_dependencies: + if not isinstance(dep, dict): + continue + check_cmd = str(dep.get("check") or "").strip() + install_cmd = str(dep.get("install") or "").strip() + if not check_cmd: + if install_cmd: + external_ok = False + continue + try: + completed = _run_setup_command( + shlex.split(check_cmd), + display=check_cmd, + timeout=20, + ) + except Exception: + external_ok = False + continue + if completed.returncode != 0: + external_ok = False + + return pip_ok and external_ok + + +def _install_memory_provider_pip_dependencies(dependencies: List[str]) -> List[Dict[str, Any]]: + missing = [dep for dep in dependencies if not _dependency_importable(dep)] + if not dependencies: + return [] + if not missing: + return [ + _command_result(kind="pip", name=", ".join(dependencies), status="already_installed") + ] + + uv_path = shutil.which("uv") + if uv_path: + command: Any = [uv_path, "pip", "install", "--python", sys.executable, "--quiet", *missing] + display = f"uv pip install --python {sys.executable} {' '.join(missing)}" + else: + command = [sys.executable, "-m", "pip", "install", "--quiet", *missing] + display = f"{sys.executable} -m pip install {' '.join(missing)}" + + try: + completed = _run_setup_command(command, display=display, timeout=240) + except Exception as exc: + return [ + _command_result( + kind="pip", + name=", ".join(missing), + status="failed", + command=display, + error=str(exc), + ) + ] + + return [ + _command_result( + kind="pip", + name=", ".join(missing), + status="installed" if completed.returncode == 0 else "failed", + command=display, + completed=completed, + ) + ] + + +def _install_memory_provider_external_dependencies( + dependencies: List[Dict[str, str]], +) -> List[Dict[str, Any]]: + results: List[Dict[str, Any]] = [] + for dep in dependencies: + name = dep.get("name") or "dependency" + check_cmd = dep.get("check") or "" + install_cmd = dep.get("install") or "" + + if check_cmd: + try: + check = _run_setup_command( + shlex.split(check_cmd), + display=check_cmd, + timeout=20, + ) + except Exception as exc: + results.append( + _command_result( + kind="external_check", + name=name, + status="missing" if install_cmd else "failed", + command=check_cmd, + error=str(exc), + ) + ) + else: + if check.returncode == 0: + results.append( + _command_result( + kind="external_check", + name=name, + status="already_installed", + command=check_cmd, + completed=check, + ) + ) + continue + results.append( + _command_result( + kind="external_check", + name=name, + status="missing" if install_cmd else "failed", + command=check_cmd, + completed=check, + ) + ) + + if not install_cmd: + continue + + if install_cmd: + try: + install = _run_setup_command( + install_cmd, + display=install_cmd, + shell=True, + timeout=300, + ) + except Exception as exc: + results.append( + _command_result( + kind="external_install", + name=name, + status="failed", + command=install_cmd, + error=str(exc), + ) + ) + continue + + results.append( + _command_result( + kind="external_install", + name=name, + status="installed" if install.returncode == 0 else "failed", + command=install_cmd, + completed=install, + ) + ) + + if check_cmd and install.returncode == 0: + try: + post_check = _run_setup_command( + shlex.split(check_cmd), + display=check_cmd, + timeout=20, + ) + results.append( + _command_result( + kind="external_check", + name=name, + status="verified" if post_check.returncode == 0 else "failed", + command=check_cmd, + completed=post_check, + ) + ) + except Exception as exc: + results.append( + _command_result( + kind="external_check", + name=name, + status="failed", + command=check_cmd, + error=str(exc), + ) + ) + + return results + + +def _install_memory_provider_setup(name: str) -> Dict[str, Any]: + provider = _load_memory_provider(name) + manifest = _memory_provider_manifest(name) + if provider is None and not manifest: + raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") + + setup = _memory_provider_setup_manifest(name) + results = [] + results.extend(_install_memory_provider_pip_dependencies(setup["pip_dependencies"])) + results.extend( + _install_memory_provider_external_dependencies(setup["external_dependencies"]) + ) + + if not results: + results.append( + _command_result( + kind="setup", + name=name, + status="no_declared_steps", + ) + ) + + ok = all(result["status"] not in {"failed"} for result in results) + statuses = {row["name"]: row for row in _discover_memory_provider_statuses()} + return { + "ok": ok, + "provider": name, + "results": results, + "status": statuses.get(name), + } + + +def _normalize_memory_provider_schema(name: str, provider: Any) -> List[Dict[str, Any]]: + raw_schema: List[Dict[str, Any]] = [] + if provider is not None and hasattr(provider, "get_config_schema"): + try: + raw = provider.get_config_schema() + if isinstance(raw, list): + raw_schema = [field for field in raw if isinstance(field, dict)] + except Exception: + _log.warning("Failed to read memory provider schema for %s", name, exc_info=True) + + fields: List[Dict[str, Any]] = [] + for raw in raw_schema: + key = str(raw.get("key") or "").strip() + if not key: + continue + + choices = raw.get("choices") or raw.get("options") or [] + if not isinstance(choices, list): + choices = [] + + explicit_kind = str(raw.get("kind") or raw.get("type") or "").strip().lower() + if raw.get("secret"): + kind = "secret" + elif choices: + kind = "select" + elif explicit_kind in {"bool", "boolean"} or isinstance(raw.get("default"), bool): + kind = "boolean" + else: + kind = "text" + + options = [] + for choice in choices: + value = str(choice) + options.append({"value": value, "label": value, "description": ""}) + + description = str(raw.get("description") or "") + fields.append({ + "key": key, + "label": str(raw.get("label") or key.replace("_", " ").title()), + "kind": kind, + "description": description, + "placeholder": str(raw.get("placeholder") or ""), + "required": bool(raw.get("required", False)), + "default": raw.get("default", ""), + "options": options, + "url": str(raw.get("url") or ""), + "when": raw.get("when") if isinstance(raw.get("when"), dict) else None, + "_env_key": str(raw.get("env_var") or "") or None, + }) + + return fields + + +def _read_json_file(path: Path) -> Dict[str, Any]: 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) + _log.debug("Failed to read JSON 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.""" +def _read_memory_provider_existing_values(name: str) -> Dict[str, Any]: + """Best-effort read of existing provider config across legacy/native stores.""" - for source_key in (field.key, *field.aliases): - value = data.get(source_key) - if value: - return str(value) + hermes_home = get_hermes_home() + values: Dict[str, Any] = {} + # Common native provider stores. + for path in ( + hermes_home / f"{name}.json", + hermes_home / name / "config.json", + ): + values.update(_read_json_file(path)) + + try: + cfg = load_config() + except Exception: + cfg = {} + + memory_cfg = cfg.get("memory") if isinstance(cfg, dict) else {} + if isinstance(memory_cfg, dict): + provider_cfg = memory_cfg.get(name) + if isinstance(provider_cfg, dict): + values.update(provider_cfg) + legacy_cfg = memory_cfg.get("provider_config") + if isinstance(legacy_cfg, dict): + values = {**legacy_cfg, **values} + + # Holographic stores under plugins.hermes-memory-store. + plugins_cfg = cfg.get("plugins") if isinstance(cfg, dict) else {} + if name == "holographic" and isinstance(plugins_cfg, dict): + holographic_cfg = plugins_cfg.get("hermes-memory-store") + if isinstance(holographic_cfg, dict): + values.update(holographic_cfg) + + return values + + +def _env_lookup(env_key: Optional[str]) -> str: + if not env_key: + return "" 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 + return str(env_on_disk.get(env_key) or os.environ.get(env_key) or "") -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 _coerce_bool(value: Any, *, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None or value == "": + return default + if isinstance(value, (int, float)): + return bool(value) + text = str(value).strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + raise ValueError(f"Invalid boolean value: {value}") -def _memory_provider_payload(provider: MemoryProvider) -> Dict[str, Any]: - data = _read_memory_provider_file(provider) - fields: List[Dict[str, Any]] = [] +def _field_default(field: Dict[str, Any]) -> Any: + default = field.get("default", "") + if field["kind"] == "boolean": + return _coerce_bool(default, default=False) + return default - 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 - ], + +def _field_value(field: Dict[str, Any], data: Dict[str, Any]) -> Any: + if field["kind"] == "secret": + return "" + + value = data.get(field["key"]) + if value in (None, ""): + value = _env_lookup(field.get("_env_key")) + if value in (None, ""): + value = _field_default(field) + + if field["kind"] == "select": + allowed = {opt["value"] for opt in field.get("options", [])} + value = str(value) + return value if value in allowed else str(_field_default(field)) + if field["kind"] == "boolean": + return _coerce_bool(value, default=_coerce_bool(_field_default(field), default=False)) + return str(value) + + +def _field_is_set(field: Dict[str, Any], data: Dict[str, Any]) -> bool: + if field["kind"] == "secret": + return bool(_env_lookup(field.get("_env_key")) or data.get(field["key"])) + value = _field_value(field, data) + return value not in (None, "") + + +def _field_visible( + field: Dict[str, Any], + data: Dict[str, Any], + fields_by_key: Optional[Dict[str, Dict[str, Any]]] = None, +) -> bool: + when = field.get("when") + if not isinstance(when, dict) or not when: + return True + for dep_key, expected in when.items(): + dep_field = (fields_by_key or {}).get(str(dep_key)) or { + "key": str(dep_key), + "kind": "text", + "default": "", + "_env_key": None, + } + actual = _field_value(dep_field, data) + if str(actual) != str(expected): + return False + return True + + +def _public_memory_provider_field(field: Dict[str, Any], data: Dict[str, Any]) -> Dict[str, Any]: + entry = { + "key": field["key"], + "label": field["label"], + "kind": field["kind"], + "description": field["description"], + "placeholder": field["placeholder"], + "required": field["required"], + "value": "" if field["kind"] == "secret" else _field_value(field, data), + "is_set": _field_is_set(field, data), + "options": field.get("options", []), + "url": field.get("url", ""), + "when": field.get("when"), + } + return entry + + +def _memory_provider_payload(name: str, provider: Any) -> Dict[str, Any]: + data = _read_memory_provider_existing_values(name) + fields = [ + _public_memory_provider_field(field, data) + for field in _normalize_memory_provider_schema(name, provider) + ] + return { + "name": name, + "label": _memory_provider_label(name), + "fields": fields, + "setup": _memory_provider_setup_info(name), + } + + +def _coerce_schema_field(field: Dict[str, Any], raw: Any) -> Any: + if field["kind"] == "boolean": + return _coerce_bool(raw, default=_coerce_bool(_field_default(field), default=False)) + + value = str(raw if raw is not None else "").strip() + if field["kind"] == "select": + if not value: + value = str(_field_default(field)) + allowed = {opt["value"] for opt in field.get("options", [])} + if value not in allowed: + raise ValueError(f"Invalid value for '{field['key']}'") + return value + + return value or _field_default(field) + + +def _save_memory_provider_native_config(name: str, provider: Any, values: Dict[str, Any]) -> None: + if provider is not None and hasattr(provider, "save_config"): + try: + from agent.memory_provider import MemoryProvider as _BaseMemoryProvider + except Exception: + provider.save_config(values, str(get_hermes_home())) + return + if type(provider).save_config is not _BaseMemoryProvider.save_config: + provider.save_config(values, str(get_hermes_home())) + return + + cfg = load_config() + memory_cfg = cfg.get("memory") + if not isinstance(memory_cfg, dict): + memory_cfg = {} + cfg["memory"] = memory_cfg + current = memory_cfg.get(name) + if not isinstance(current, dict): + current = {} + current.update(values) + memory_cfg[name] = current + save_config(cfg) + + +def _memory_provider_is_configured(name: str, provider: Any) -> bool: + data = _read_memory_provider_existing_values(name) + fields = _normalize_memory_provider_schema(name, provider) + fields_by_key = {field["key"]: field for field in fields} + visible_fields = [ + field for field in fields if _field_visible(field, data, fields_by_key) + ] + required_fields = [field for field in visible_fields if field.get("required")] + if not required_fields: + return True + return all(_field_is_set(field, data) for field in required_fields) + + +def _discover_memory_provider_statuses() -> List[Dict[str, Any]]: + discovered: Dict[str, Dict[str, Any]] = {} + try: + from plugins.memory import discover_memory_providers + + for name, description, available in discover_memory_providers(): + discovered[str(name)] = { + "name": str(name), + "description": str(description or ""), + "available": bool(available), + "missing": False, + } + except Exception: + _log.exception("discover_memory_providers failed") + + cfg = load_config() + active = "" + mem = cfg.get("memory") + if isinstance(mem, dict): + active = _normalize_memory_provider_name(mem.get("provider")) + if active and active not in discovered: + discovered[active] = { + "name": active, + "description": "Configured provider was not found.", + "available": False, + "missing": True, } - 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) + providers: List[Dict[str, Any]] = [] + for name in sorted(discovered): + row = discovered[name] + provider = None if row["missing"] else _load_memory_provider(name) + setup = _memory_provider_setup_info(name) + configured = False if row["missing"] else _memory_provider_is_configured(name, provider) + schema_fields = [] if row["missing"] else _normalize_memory_provider_schema(name, provider) + if row["missing"]: + status = "missing" + elif not row["available"] and not setup.get("dependencies_installed", True): + status = "unavailable" + elif not configured: + status = "needs_config" + elif not row["available"] and schema_fields: + status = "needs_config" + elif not row["available"]: + status = "unavailable" 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} + status = "ready" + providers.append({ + "name": name, + "description": row["description"], + "available": row["available"], + "configured": configured, + "status": status, + "setup": setup, + }) + return providers -def _coerce_field_value(field: ProviderField, raw: str) -> str: - """Validate and normalize a submitted non-secret value, or raise ValueError.""" +def _require_memory_provider_ready(name: str) -> None: + if not name: + return + statuses = {row["name"]: row for row in _discover_memory_provider_statuses()} + row = statuses.get(name) + if row is None: + raise HTTPException( + status_code=400, + detail=f"Unknown memory provider '{name}'.", + ) + if row["status"] != "ready": + raise HTTPException( + status_code=400, + detail=( + f"Memory provider '{name}' is not ready " + f"({row['status'].replace('_', ' ')}). Configure it in the dashboard first." + ), + ) - 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 + +def _write_memory_provider_config_values( + name: str, + provider: Any, + values: Dict[str, Any], +) -> None: + existing = _read_memory_provider_existing_values(name) + fields = _normalize_memory_provider_schema(name, provider) + fields_by_key = {field["key"]: field for field in fields} + config_values: Dict[str, Any] = {} + secrets: Dict[str, str] = {} + + for field in fields: + if not _field_visible(field, {**existing, **config_values}, fields_by_key): + continue + + if field["kind"] == "secret": + submitted = str(values.get(field["key"]) or "").strip() + if submitted and field.get("_env_key"): + secrets[str(field["_env_key"])] = submitted + continue + + raw = ( + values[field["key"]] + if field["key"] in values + else existing.get(field["key"], _field_default(field)) + ) + config_values[field["key"]] = _coerce_schema_field(field, raw) + + _save_memory_provider_native_config(name, provider, config_values) + + for env_key, secret in secrets.items(): + save_env_value(env_key, secret) @app.get("/api/memory/providers/{name}/config") async def get_memory_provider_config(name: str): - provider = get_memory_provider(name) + provider = _load_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) + return {"name": name, "label": name, "fields": [], "setup": _memory_provider_setup_info(name)} + return _memory_provider_payload(name, provider) + + +@app.post("/api/memory/providers/{name}/setup") +async def setup_memory_provider(name: str, body: MemoryProviderSetupRequest): + provider = _load_memory_provider(name) + if provider is not None and body.values: + try: + _write_memory_provider_config_values(name, provider, body.values) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception: + _log.exception("Failed to persist memory provider setup values for %s", name) + raise HTTPException(status_code=500, detail="Internal server error") + return _install_memory_provider_setup(name) @app.put("/api/memory/providers/{name}/config") async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate): - provider = get_memory_provider(name) + provider = _load_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) + _write_memory_provider_config_values(name, provider, values) + _require_memory_provider_ready(name) 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 + memory_config["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} + return {"ok": True, "active": name} except HTTPException: raise except ValueError as exc: @@ -10036,11 +10680,9 @@ async def remove_credential_pool_entry(provider: str, index: int): # --------------------------------------------------------------------------- # Memory provider endpoints — status / list providers / select / disable / reset. # -# Selecting a provider only writes config.memory.provider (full interactive -# provider setup, with its API-key prompts, stays on the CLI via -# `hermes memory setup`). The dashboard covers the common admin actions: -# see which provider is active, switch the built-in store on/off, and wipe -# built-in memory files. +# Provider setup is dashboard-native when a provider exposes get_config_schema(). +# The dashboard never runs interactive provider setup hooks; activation is only +# allowed once the provider is discoverable, available, and has required config. # --------------------------------------------------------------------------- @@ -10056,24 +10698,11 @@ class MemoryReset(BaseModel): @app.get("/api/memory") async def get_memory_status(): - from plugins.memory import discover_memory_providers - cfg = load_config() active = "" mem = cfg.get("memory") if isinstance(mem, dict): - active = str(mem.get("provider") or "") - - providers = [] - try: - for name, description, configured in discover_memory_providers(): - providers.append({ - "name": name, - "description": description, - "configured": bool(configured), - }) - except Exception: - _log.exception("discover_memory_providers failed") + active = _normalize_memory_provider_name(mem.get("provider")) # Built-in memory file sizes (so the UI can show what a reset would erase). mem_dir = get_hermes_home() / "memories" @@ -10084,26 +10713,16 @@ async def get_memory_status(): return { "active": active, - "providers": providers, + "providers": _discover_memory_provider_statuses(), "builtin_files": files, } @app.put("/api/memory/provider") async def set_memory_provider(body: MemoryProviderSelect): - provider = (body.provider or "").strip() - if provider.lower() in {"built-in", "builtin", "none"}: - provider = "" + provider = _normalize_memory_provider_name(body.provider) - if provider: - from plugins.memory import discover_memory_providers - - valid = {name for name, _d, _c in discover_memory_providers()} - if provider not in valid: - raise HTTPException( - status_code=400, - detail=f"Unknown memory provider '{provider}'. Run `hermes memory setup` to configure a new one.", - ) + _require_memory_provider_ready(provider) cfg = load_config() if not isinstance(cfg.get("memory"), dict): @@ -14910,7 +15529,6 @@ def _merged_plugins_hub() -> Dict[str, Any]: _get_current_context_engine, _get_current_memory_provider, _discover_context_engines, - _discover_memory_providers, _get_disabled_set, _get_enabled_set, _read_manifest as _read_plugin_manifest_at, @@ -14998,12 +15616,7 @@ def _merged_plugins_hub() -> Dict[str, Any]: if str(p["name"]) not in agent_names ] - memory_providers: List[Dict[str, str]] = [] - try: - for n, desc in _discover_memory_providers(): - memory_providers.append({"name": n, "description": desc}) - except Exception: - memory_providers = [] + memory_providers = _discover_memory_provider_statuses() context_engines: List[Dict[str, str]] = [] try: @@ -15016,7 +15629,7 @@ def _merged_plugins_hub() -> Dict[str, Any]: "plugins": rows, "orphan_dashboard_plugins": orphan_dashboard, "providers": { - "memory_provider": _get_current_memory_provider() or "", + "memory_provider": _normalize_memory_provider_name(_get_current_memory_provider()), "memory_options": memory_providers, "context_engine": _get_current_context_engine(), "context_options": context_engines, @@ -15129,7 +15742,9 @@ async def put_plugin_providers(request: Request, body: _PluginProvidersPutBody): ) if body.memory_provider is not None: - _save_memory_provider(body.memory_provider) + memory_provider = _normalize_memory_provider_name(body.memory_provider) + _require_memory_provider_ready(memory_provider) + _save_memory_provider(memory_provider) if body.context_engine is not None: _save_context_engine(body.context_engine) return {"ok": True} diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index dc1bfcccf793..d8d164d11ada 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -391,12 +391,149 @@ class TestWebServerEndpoints: 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 {opt["value"] for opt in fields["mode"]["options"]} >= { + "cloud", + "local_external", + } + assert fields["api_url"]["kind"] == "text" + assert fields["api_url"]["value"] 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 + assert fields["api_key"]["required"] is False + + def test_get_memory_provider_config_loads_dynamic_plugin_schema(self): + resp = self.client.get("/api/memory/providers/honcho/config") + + assert resp.status_code == 200 + data = resp.json() + fields = self._provider_field_map(data) + assert fields["api_key"]["kind"] == "secret" + assert fields["api_key"]["url"] == "https://app.honcho.dev" + assert fields["baseUrl"]["kind"] == "text" + + def test_all_listed_memory_provider_configs_fetch(self): + resp = self.client.get("/api/memory") + + assert resp.status_code == 200 + providers = resp.json()["providers"] + assert providers + + failures = [] + for provider in providers: + config_resp = self.client.get( + f"/api/memory/providers/{provider['name']}/config" + ) + if config_resp.status_code != 200: + failures.append((provider["name"], config_resp.status_code, config_resp.text)) + + assert failures == [] + + def test_memory_provider_payloads_include_manifest_setup_hints(self): + resp = self.client.get("/api/memory") + + assert resp.status_code == 200 + providers = {row["name"]: row for row in resp.json()["providers"]} + + byterover_setup = providers["byterover"]["setup"] + assert byterover_setup["external_dependencies"] == [ + { + "name": "brv", + "install": "curl -fsSL https://byterover.dev/install.sh | sh", + "check": "brv --version", + } + ] + + retaindb_setup = providers["retaindb"]["setup"] + assert "requests" in retaindb_setup["pip_dependencies"] + assert "RETAINDB_API_KEY" in retaindb_setup["required_env"] + assert isinstance(byterover_setup["dependencies_installed"], bool) + + config_resp = self.client.get("/api/memory/providers/byterover/config") + assert config_resp.status_code == 200 + assert config_resp.json()["setup"]["external_dependencies"] == byterover_setup["external_dependencies"] + + def test_memory_status_reports_honcho_needs_config_after_dependency_setup(self, monkeypatch): + import hermes_cli.web_server as web_server + + original_dependency_importable = web_server._dependency_importable + monkeypatch.setattr( + web_server, + "_dependency_importable", + lambda dep: True if dep == "honcho-ai" else original_dependency_importable(dep), + ) + + resp = self.client.get("/api/memory") + + assert resp.status_code == 200 + providers = {row["name"]: row for row in resp.json()["providers"]} + assert providers["honcho"]["setup"]["dependencies_installed"] is True + assert providers["honcho"]["status"] == "needs_config" + + def test_post_memory_provider_setup_runs_declared_external_install(self, monkeypatch): + import subprocess + + import hermes_cli.web_server as web_server + + calls = [] + check_count = 0 + + def fake_run(command, **kwargs): + nonlocal check_count + calls.append((command, kwargs)) + if command == ["brv", "--version"]: + check_count += 1 + if check_count == 1: + raise FileNotFoundError("brv") + return subprocess.CompletedProcess( + command, + 0, + stdout="brv 1.0.0", + stderr="", + ) + if command == "curl -fsSL https://byterover.dev/install.sh | sh": + assert kwargs["shell"] is True + return subprocess.CompletedProcess(command, 0, stdout="installed", stderr="") + raise AssertionError(f"Unexpected command: {command}") + + monkeypatch.setattr(web_server.subprocess, "run", fake_run) + + resp = self.client.post("/api/memory/providers/byterover/setup", json={"values": {}}) + + assert resp.status_code == 200 + data = resp.json() + assert data["provider"] == "byterover" + assert data["ok"] is True + assert [result["status"] for result in data["results"]] == [ + "missing", + "installed", + "verified", + ] + assert [call[0] for call in calls[:3]] == [ + ["brv", "--version"], + "curl -fsSL https://byterover.dev/install.sh | sh", + ["brv", "--version"], + ] + assert calls[-1][0] == ["brv", "--version"] + + def test_post_unknown_memory_provider_setup_returns_404(self): + resp = self.client.post("/api/memory/providers/nope/setup", json={"values": {}}) + + assert resp.status_code == 404 + + def test_post_memory_provider_setup_persists_values_without_activation(self): + from hermes_cli.config import load_config, load_env + + resp = self.client.post( + "/api/memory/providers/retaindb/setup", + json={"values": {"api_key": "retain-test-key", "project": "default"}}, + ) + + assert resp.status_code == 200 + assert resp.json()["provider"] == "retaindb" + assert load_env()["RETAINDB_API_KEY"] == "retain-test-key" + assert load_config().get("memory", {}).get("provider") != "retaindb" def test_put_memory_provider_config_writes_config_and_secret(self): from hermes_constants import get_hermes_home @@ -416,25 +553,24 @@ class TestWebServerEndpoints: ) assert resp.status_code == 200 - assert resp.json() == {"ok": True} + assert resp.json() == {"ok": True, "active": "hindsight"} 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", - } + assert provider_config["mode"] == "local_external" + assert provider_config["api_url"] == "http://localhost:8888" + assert provider_config["bank_id"] == "ben-bank" + assert provider_config["recall_budget"] == "high" + assert "api_key" not in provider_config 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", + "mode": "spaceship", "api_url": "http://localhost:8888", "bank_id": "hermes", "recall_budget": "mid", @@ -480,6 +616,77 @@ class TestWebServerEndpoints: assert fields["api_key"]["value"] == "" assert "secret-value" not in json.dumps(data) + def test_get_memory_status_reports_ready_and_missing_provider(self): + from hermes_cli.config import load_config, save_config + + 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") + assert resp.status_code == 200 + providers = {row["name"]: row for row in resp.json()["providers"]} + assert providers["hindsight"]["configured"] is True + assert providers["hindsight"]["status"] == "ready" + assert "available" in providers["hindsight"] + + config = load_config() + config.setdefault("memory", {})["provider"] = "not-installed" + save_config(config) + + resp = self.client.get("/api/memory") + assert resp.status_code == 200 + providers = {row["name"]: row for row in resp.json()["providers"]} + assert providers["not-installed"]["status"] == "missing" + assert providers["not-installed"]["available"] is False + + config = load_config() + config.setdefault("memory", {})["provider"] = "builtin" + save_config(config) + + resp = self.client.get("/api/memory") + assert resp.status_code == 200 + assert resp.json()["active"] == "" + assert "builtin" not in {row["name"] for row in resp.json()["providers"]} + + def test_set_memory_provider_rejects_unready_and_clears_builtin(self): + from hermes_cli.config import load_config + + resp = self.client.put("/api/memory/provider", json={"provider": "supermemory"}) + assert resp.status_code == 400 + + resp = self.client.put("/api/memory/provider", json={"provider": "built-in"}) + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "active": ""} + assert load_config()["memory"]["provider"] == "" + + def test_dashboard_plugin_providers_rejects_unready_memory_provider(self): + resp = self.client.put( + "/api/dashboard/plugin-providers", + json={"memory_provider": "supermemory"}, + ) + + assert resp.status_code == 400 + + def test_dashboard_plugin_providers_accepts_builtin_alias(self): + from hermes_cli.config import load_config + + resp = self.client.put( + "/api/dashboard/plugin-providers", + json={"memory_provider": "built-in"}, + ) + + assert resp.status_code == 200 + assert load_config()["memory"]["provider"] == "" + def test_get_moa_models_returns_provider_model_slots(self): resp = self.client.get("/api/model/moa") assert resp.status_code == 200 diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 25128b306a44..5dc982430a9a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1070,6 +1070,28 @@ export const api = { // ── Admin: Memory provider ────────────────────────────────────────── getMemory: () => fetchJSON("/api/memory"), + getMemoryProviderConfig: (provider: string) => + fetchJSON( + `/api/memory/providers/${encodeURIComponent(provider)}/config`, + ), + updateMemoryProviderConfig: (provider: string, values: Record) => + fetchJSON<{ ok: boolean; active: string }>( + `/api/memory/providers/${encodeURIComponent(provider)}/config`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ values }), + }, + ), + setupMemoryProvider: (provider: string, values: Record = {}) => + fetchJSON( + `/api/memory/providers/${encodeURIComponent(provider)}/setup`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ values }), + }, + ), setMemoryProvider: (provider: string) => fetchJSON<{ ok: boolean; active: string }>("/api/memory/provider", { method: "PUT", @@ -1524,7 +1546,10 @@ export interface CredentialPoolProvider { export interface MemoryProviderInfo { name: string; description: string; + available: boolean; configured: boolean; + status: "ready" | "needs_config" | "unavailable" | "missing"; + setup?: MemoryProviderSetupInfo; } export interface MemoryStatus { @@ -1533,6 +1558,63 @@ export interface MemoryStatus { builtin_files: { memory: number; user: number }; } +export interface MemoryProviderExternalDependency { + name: string; + install: string; + check: string; +} + +export interface MemoryProviderSetupInfo { + pip_dependencies: string[]; + external_dependencies: MemoryProviderExternalDependency[]; + required_env: string[]; + dependencies_installed: boolean; +} + +export interface MemoryProviderSetupResult { + kind: string; + name: string; + status: string; + command: string; + returncode: number | null; + stdout: string; + stderr: string; +} + +export interface MemoryProviderSetupResponse { + ok: boolean; + provider: string; + results: MemoryProviderSetupResult[]; + status?: MemoryProviderInfo | null; +} + +export interface MemoryProviderFieldOption { + value: string; + label: string; + description?: string; +} + +export interface MemoryProviderField { + key: string; + label: string; + kind: "text" | "secret" | "select" | "boolean"; + description: string; + placeholder: string; + required: boolean; + value: string | boolean; + is_set: boolean; + options: MemoryProviderFieldOption[]; + url: string; + when?: Record | null; +} + +export interface MemoryProviderConfig { + name: string; + label: string; + fields: MemoryProviderField[]; + setup?: MemoryProviderSetupInfo; +} + export interface HookEntry { event: string; matcher: string | null; @@ -2317,7 +2399,7 @@ export interface HubAgentPluginRow { export interface PluginsHubProviders { memory_provider: string; - memory_options: Array<{ name: string; description: string }>; + memory_options: MemoryProviderInfo[]; context_engine: string; context_options: Array<{ name: string; description: string }>; } diff --git a/web/src/pages/PluginsPage.tsx b/web/src/pages/PluginsPage.tsx index 97790ce631d8..52baf89cbf9c 100644 --- a/web/src/pages/PluginsPage.tsx +++ b/web/src/pages/PluginsPage.tsx @@ -3,13 +3,21 @@ import { ExternalLink, RefreshCw, Trash2, Eye, EyeOff } from "lucide-react"; import type { Translations } from "@/i18n/types"; import { Link } from "react-router-dom"; import { api } from "@/lib/api"; -import type { HubAgentPluginRow, PluginsHubResponse } from "@/lib/api"; +import type { + HubAgentPluginRow, + MemoryProviderConfig, + MemoryProviderField, + MemoryProviderInfo, + MemoryProviderSetupInfo, + MemoryProviderSetupResult, + PluginsHubResponse, +} from "@/lib/api"; import { Button } from "@nous-research/ui/ui/components/button"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; import { Switch } from "@nous-research/ui/ui/components/switch"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; -import { CommandBlock } from "@nous-research/ui/ui/components/command-block"; +import { CommandBlock, CopyButton } from "@nous-research/ui/ui/components/command-block"; import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card"; import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog"; import { Input } from "@nous-research/ui/ui/components/input"; @@ -24,6 +32,252 @@ import { usePageHeader } from "@/contexts/usePageHeader"; /** Select value for built-in memory (`config` uses empty string). Never use `""` — UI Select maps empty value to an empty label. */ const MEMORY_PROVIDER_BUILTIN = "__hermes_memory_builtin__"; +type MemoryFormValue = string | boolean; + +const MEMORY_STATUS_LABEL: Record = { + ready: "ready", + needs_config: "needs setup", + unavailable: "unavailable", + missing: "missing", +}; + +const MEMORY_STATUS_TONE: Record = { + ready: "success", + needs_config: "warning", + unavailable: "destructive", + missing: "destructive", +}; + +function fieldInitialValue(field: MemoryProviderField): MemoryFormValue { + if (field.kind === "secret") return ""; + if (field.kind === "boolean") return Boolean(field.value); + return String(field.value ?? ""); +} + +function fieldIsVisible(field: MemoryProviderField, values: Record) { + if (!field.when) return true; + return Object.entries(field.when).every(([key, expected]) => { + const current = values[key]; + return String(current ?? "") === String(expected); + }); +} + +function setupHasDetails(setup?: MemoryProviderSetupInfo) { + if (!setup) return false; + return Boolean( + setup.external_dependencies?.length || + setup.pip_dependencies?.length || + setup.required_env?.length, + ); +} + +function setupHasInstallableSteps(setup?: MemoryProviderSetupInfo) { + if (!setup) return false; + return Boolean( + setup.external_dependencies?.some((dep) => dep.install) || + setup.pip_dependencies?.length, + ); +} + +function SetupCommandBlock({ code, label }: { code: string; label: string }) { + return ( +
+
+ {label} + +
+
+ {code} +
+
+ ); +} + +function setupResultLabel(status: string) { + if (status === "already_installed") return "already installed"; + if (status === "no_declared_steps") return "no declared setup"; + return status.replace(/_/g, " "); +} + +function setupResultClass(status: string) { + if (status === "failed") return "border-destructive/50 text-destructive"; + if (status === "installed" || status === "verified" || status === "already_installed") { + return "border-success/50 text-success"; + } + if (status === "missing") return "border-warning/50 text-warning"; + return "border-border text-muted-foreground"; +} + +function MemoryProviderSetupResults({ results }: { results: MemoryProviderSetupResult[] }) { + if (!results.length) return null; + + return ( +
+

Setup results

+ {results.map((result, index) => { + const detail = result.stderr || result.stdout; + return ( +
+
+ + {setupResultLabel(result.status)} + + + {result.name} + {result.kind ? ` (${result.kind.replace(/_/g, " ")})` : ""} + +
+ {result.command ? ( + + {result.command} + + ) : null} + {detail ? ( +
+                {detail}
+              
+ ) : null} +
+ ); + })} +
+ ); +} + +function MemoryProviderSetupHint({ + installing, + onInstall, + provider, + results, +}: { + installing: boolean; + onInstall: () => void; + provider: MemoryProviderInfo; + results: MemoryProviderSetupResult[] | null; +}) { + const setup = provider.setup; + const hasDetails = setupHasDetails(setup); + const hasInstallableSteps = setupHasInstallableSteps(setup); + const dependenciesInstalled = setup?.dependencies_installed ?? !hasInstallableSteps; + const hasResults = Boolean(results?.length); + const needsDependencySetup = hasInstallableSteps && !dependenciesInstalled; + const isBlocked = provider.status === "unavailable" && needsDependencySetup; + const shouldShow = + hasResults || + needsDependencySetup || + (provider.status === "unavailable" && hasDetails && !dependenciesInstalled); + + if (!shouldShow) return null; + + if (!hasDetails || !setup) { + return ( +

+ This provider is installed but unavailable. It may need local dependencies or a manual setup step before Hermes can activate it. +

+ ); + } + + return ( +
+

+ {needsDependencySetup + ? "Finish these setup steps before Hermes can activate this provider." + : "Provider dependency setup completed."} +

+ + {needsDependencySetup ? ( + + ) : null} + + {installing ? ( +
+ Running provider setup. This may take a minute… +
+ ) : null} + + {results ? : null} + + {needsDependencySetup ? ( + <> + {setup.external_dependencies.map((dep, index) => ( +
+

+ External dependency{dep.name ? `: ${dep.name}` : ""} +

+ {dep.install ? ( + + ) : null} + {dep.check ? ( + + ) : null} +
+ ))} + + {setup.pip_dependencies.length ? ( +
+

Python dependencies

+
+ {setup.pip_dependencies.map((dep) => ( + + {dep} + + ))} +
+
+ ) : null} + + ) : null} + + {setup.required_env.length && needsDependencySetup ? ( +
+

+ Required environment values. Fill the matching fields below, or set them in the Hermes environment. +

+
+ {setup.required_env.map((envKey) => ( + + {envKey} + + ))} +
+
+ ) : null} +
+ ); +} + export default function PluginsPage() { const [hub, setHub] = useState(null); const [loading, setLoading] = useState(true); @@ -33,46 +287,83 @@ export default function PluginsPage() { const [installBusy, setInstallBusy] = useState(false); const [rescanBusy, setRescanBusy] = useState(false); const [memorySel, setMemorySel] = useState(MEMORY_PROVIDER_BUILTIN); + const [memoryConfig, setMemoryConfig] = useState(null); + const [memoryValues, setMemoryValues] = useState>({}); + const [memoryConfigBusy, setMemoryConfigBusy] = useState(false); + const [secretVisible, setSecretVisible] = useState>({}); const [contextSel, setContextSel] = useState("compressor"); - const [providerBusy, setProviderBusy] = useState(false); + const [memoryBusy, setMemoryBusy] = useState(false); + const [memorySetupBusy, setMemorySetupBusy] = useState(false); + const [memorySetupResults, setMemorySetupResults] = useState(null); + const [contextBusy, setContextBusy] = useState(false); const [rowBusy, setRowBusy] = useState(null); const { toast, showToast } = useToast(); const { t } = useI18n(); const { setAfterTitle } = usePageHeader(); - const loadHub = useCallback(() => { + const loadHub = useCallback((memorySelection?: string) => { return api .getPluginsHub() .then((h) => { setHub(h); const p = h.providers; - setMemorySel(p.memory_provider ? p.memory_provider : MEMORY_PROVIDER_BUILTIN); + setMemorySel( + memorySelection ?? (p.memory_provider ? p.memory_provider : MEMORY_PROVIDER_BUILTIN), + ); setContextSel(p.context_engine || "compressor"); }) .catch(() => showToast(t.common.loading, "error")); }, [showToast, t.common.loading]); useEffect(() => { - setLoading(true); void loadHub().finally(() => setLoading(false)); }, [loadHub]); useEffect(() => { - setAfterTitle( - , - ); - return () => setAfterTitle(null); - }, [loading, rescanBusy, setAfterTitle, t.pluginsPage.refreshDashboard]); + const provider = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel; + let cancelled = false; + + void Promise.resolve().then(() => { + if (cancelled) return; + setSecretVisible({}); + setMemorySetupResults(null); + + if (!provider) { + setMemoryConfig(null); + setMemoryValues({}); + setMemoryConfigBusy(false); + return; + } + + setMemoryConfigBusy(true); + api + .getMemoryProviderConfig(provider) + .then((config) => { + if (cancelled) return; + setMemoryConfig(config); + setMemoryValues( + Object.fromEntries( + config.fields.map((field) => [field.key, fieldInitialValue(field)]), + ), + ); + }) + .catch((e) => { + if (!cancelled) { + setMemoryConfig(null); + setMemoryValues({}); + showToast(e instanceof Error ? e.message : "Failed to load provider config", "error"); + } + }) + .finally(() => { + if (!cancelled) setMemoryConfigBusy(false); + }); + }); + + return () => { + cancelled = true; + }; + }, [memorySel, showToast]); const onInstall = async () => { const id = installId.trim(); @@ -100,7 +391,7 @@ export default function PluginsPage() { } }; - const onRescan = async () => { + const onRescan = useCallback(async () => { setRescanBusy(true); try { const rc = await api.rescanPlugins(); @@ -114,22 +405,90 @@ export default function PluginsPage() { } finally { setRescanBusy(false); } - }; + }, [loadHub, showToast, t.pluginsPage.refreshDashboard]); - const onSaveProviders = async () => { - setProviderBusy(true); + useEffect(() => { + setAfterTitle( + , + ); + return () => setAfterTitle(null); + }, [loading, onRescan, rescanBusy, setAfterTitle, t.pluginsPage.refreshDashboard]); + + const onSaveMemoryProvider = async () => { + const provider = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel; + setMemoryBusy(true); try { - await api.savePluginProviders({ - memory_provider: - memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel, - context_engine: contextSel, - }); + if (!provider) { + await api.setMemoryProvider(""); + } else { + const visibleValues = Object.fromEntries( + Object.entries(memoryValues).filter(([key]) => { + const field = memoryConfig?.fields.find((candidate) => candidate.key === key); + return field ? fieldIsVisible(field, memoryValues) : true; + }), + ); + await api.updateMemoryProviderConfig(provider, visibleValues); + } showToast(t.pluginsPage.savedProviders, "success"); await loadHub(); } catch (e) { showToast(e instanceof Error ? e.message : "Save failed", "error"); } finally { - setProviderBusy(false); + setMemoryBusy(false); + } + }; + + const currentVisibleMemoryValues = () => + Object.fromEntries( + Object.entries(memoryValues).filter(([key]) => { + const field = memoryConfig?.fields.find((candidate) => candidate.key === key); + return field ? fieldIsVisible(field, memoryValues) : true; + }), + ); + + const onSetupMemoryProvider = async () => { + const provider = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel; + if (!provider) return; + + setMemorySetupBusy(true); + setMemorySetupResults(null); + try { + const result = await api.setupMemoryProvider(provider, currentVisibleMemoryValues()); + setMemorySetupResults(result.results); + const failed = result.results.filter((row) => row.status === "failed"); + if (failed.length) { + const names = Array.from(new Set(failed.map((row) => row.name))).join(", "); + showToast(`Provider setup failed: ${names || provider}. See setup results below.`, "error"); + } else { + showToast("Provider setup finished", "success"); + } + await loadHub(provider); + } catch (e) { + showToast(e instanceof Error ? e.message : "Provider setup failed", "error"); + } finally { + setMemorySetupBusy(false); + } + }; + + const onSaveContextEngine = async () => { + setContextBusy(true); + try { + await api.savePluginProviders({ context_engine: contextSel }); + showToast(t.pluginsPage.savedProviders, "success"); + await loadHub(); + } catch (e) { + showToast(e instanceof Error ? e.message : "Save failed", "error"); + } finally { + setContextBusy(false); } }; @@ -147,6 +506,15 @@ export default function PluginsPage() { const rows = hub?.plugins ?? []; const providers = hub?.providers; + const selectedMemoryName = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel; + const selectedMemoryInfo = selectedMemoryName + ? providers?.memory_options.find((provider) => provider.name === selectedMemoryName) + : null; + const activeMemoryInfo = providers?.memory_provider + ? providers.memory_options.find((provider) => provider.name === providers.memory_provider) + : null; + const visibleMemoryFields = + memoryConfig?.fields.filter((field) => fieldIsVisible(field, memoryValues)) ?? []; return (
@@ -159,65 +527,230 @@ export default function PluginsPage() { {t.pluginsPage.providersHeading}

- {t.pluginsPage.providersHint} + Configure memory providers and runtime context engine selection.

+
+
+
+
+ + {selectedMemoryName && selectedMemoryInfo && ( + + {MEMORY_STATUS_LABEL[selectedMemoryInfo.status]} + + )} + {selectedMemoryName && selectedMemoryName === providers.memory_provider && ( + active + )} + {!selectedMemoryName && !providers.memory_provider && ( + active + )} +
-
-
- - - -
- -
- - - + + {`(${t.pluginsPage.providerDefaults})`} - ))} - -
-
- + {providers.memory_options.map((o) => ( + + {o.name} + + ))} + +
+ + {!selectedMemoryName && ( +

+ Hermes will use the built-in MEMORY.md and USER.md files. +

+ )} + + {activeMemoryInfo?.status === "missing" && ( +

+ Active provider {providers.memory_provider} is no longer installed. Select another provider and save. +

+ )} + + {selectedMemoryName && selectedMemoryInfo?.description && ( +

+ {selectedMemoryInfo.description} +

+ )} + + {selectedMemoryName && selectedMemoryInfo && ( + void onSetupMemoryProvider()} + provider={selectedMemoryInfo} + results={memorySetupResults} + /> + )} + + {selectedMemoryName && selectedMemoryInfo?.status === "needs_config" && ( +

+ Provider dependencies are installed. Add the required credentials or self-hosted URL below, then save the provider. +

+ )} + + {selectedMemoryName && memoryConfigBusy && ( +
+ Loading provider settings… +
+ )} + + {selectedMemoryName && !memoryConfigBusy && visibleMemoryFields.length === 0 && ( +

+ This provider does not expose dashboard settings. +

+ )} + + {selectedMemoryName && !memoryConfigBusy && visibleMemoryFields.length > 0 && ( +
+ {visibleMemoryFields.map((field) => { + const value = memoryValues[field.key]; + const secretIsVisible = !!secretVisible[field.key]; + return ( +
+
+ + {field.required && required} + {field.kind === "secret" && field.is_set && !value && ( + set + )} + {field.url && ( + + Open + + )} +
+ + {field.kind === "select" ? ( + + ) : field.kind === "boolean" ? ( + + setMemoryValues((current) => ({ ...current, [field.key]: next })) + } + /> + ) : ( +
+ + setMemoryValues((current) => ({ + ...current, + [field.key]: event.target.value, + })) + } + /> + {field.kind === "secret" && ( + + )} +
+ )} + + {field.description && ( +

{field.description}

+ )} +
+ ); + })} +
+ )} + + +
+ +
+ + + + + +
+
)} diff --git a/web/src/pages/SystemPage.tsx b/web/src/pages/SystemPage.tsx index 82aed6b2bd34..e32f752eb30c 100644 --- a/web/src/pages/SystemPage.tsx +++ b/web/src/pages/SystemPage.tsx @@ -48,6 +48,7 @@ import { api } from "@/lib/api"; import type { StatusResponse, MemoryStatus, + MemoryProviderInfo, CredentialPoolProvider, CheckpointsResponse, HooksResponse, @@ -171,6 +172,23 @@ const HOOK_EVENTS_FALLBACK = [ "on_session_end", ]; +const MEMORY_STATUS_LABEL: Record = { + ready: "ready", + needs_config: "needs setup", + unavailable: "unavailable", + missing: "missing", +}; + +const MEMORY_STATUS_TONE: Record< + MemoryProviderInfo["status"], + "success" | "warning" | "destructive" | "secondary" +> = { + ready: "success", + needs_config: "warning", + unavailable: "destructive", + missing: "destructive", +}; + export default function SystemPage() { const { toast, showToast } = useToast(); @@ -620,6 +638,9 @@ export default function SystemPage() { const gatewayRunning = status?.gateway_running; const canUpdateHermes = status?.can_update_hermes !== false; + const activeMemoryProvider = memory?.active + ? memory.providers.find((provider) => provider.name === memory.active) + : null; const validEvents = hooks?.valid_events?.length ? hooks.valid_events : HOOK_EVENTS_FALLBACK; @@ -1077,15 +1098,28 @@ export default function SystemPage() { {memory?.active || "built-in only"} + {activeMemoryProvider && ( + + {MEMORY_STATUS_LABEL[activeMemoryProvider.status]} + + )} Change in Plugins → - New credentials:{" "} - hermes memory setup + Provider setup:{" "} + + configure in Plugins +
+ {activeMemoryProvider?.status === "missing" && ( +

+ The configured provider is no longer installed. Switch to built-in memory or configure another provider in Plugins. +

+ )} +
Built-in files — MEMORY.md:{" "} From d9a4b5a5e5c35b0a9ab050814c47346997f87afd Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:11:29 -0700 Subject: [PATCH 184/610] fix: validate memory provider names before filesystem lookup and setup commands Strict charset allowlist (alnum + - _, max 64) on the {name} path param of the memory-provider config/setup endpoints. Prevents traversal-shaped names from reaching find_provider_dir(), and setup now 404s when neither a loadable provider nor a plugin manifest exists, so the command-running path is only reachable for discoverable plugins. Adds regression tests. --- hermes_cli/web_server.py | 24 ++++++++++++++++++++++++ tests/hermes_cli/test_web_server.py | 17 +++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b14b38b5a6ac..e45d47848fb8 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4895,8 +4895,24 @@ def _write_memory_provider_config_values( save_env_value(env_key, secret) +_MEMORY_PROVIDER_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") + + +def _require_valid_memory_provider_name(name: str) -> None: + """Reject provider names that could traverse outside the plugin dirs. + + ``name`` is interpolated into filesystem paths by ``find_provider_dir()`` + and gates which plugin manifest's setup commands run. A strict charset + allowlist (no path separators, no dots) makes traversal impossible + regardless of how the downstream lookup evolves. + """ + if not _MEMORY_PROVIDER_NAME_RE.fullmatch(name or ""): + raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") + + @app.get("/api/memory/providers/{name}/config") async def get_memory_provider_config(name: str): + _require_valid_memory_provider_name(name) provider = _load_memory_provider(name) if provider is None: # Undeclared providers (e.g. builtin) have no config surface. Return an @@ -4907,7 +4923,14 @@ async def get_memory_provider_config(name: str): @app.post("/api/memory/providers/{name}/setup") async def setup_memory_provider(name: str, body: MemoryProviderSetupRequest): + _require_valid_memory_provider_name(name) provider = _load_memory_provider(name) + if provider is None and not _memory_provider_manifest(name): + # No discoverable plugin directory → nothing whose manifest could + # legitimately declare setup commands. Refuse before the + # command-running path. (provider may be None with a manifest present + # when its pip deps aren't installed yet — that's the setup use case.) + raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") if provider is not None and body.values: try: _write_memory_provider_config_values(name, provider, body.values) @@ -4921,6 +4944,7 @@ async def setup_memory_provider(name: str, body: MemoryProviderSetupRequest): @app.put("/api/memory/providers/{name}/config") async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate): + _require_valid_memory_provider_name(name) provider = _load_memory_provider(name) if provider is None: raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index d8d164d11ada..035dddffe4c4 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -522,6 +522,23 @@ class TestWebServerEndpoints: assert resp.status_code == 404 + def test_memory_provider_endpoints_reject_traversal_names(self): + # Names with path separators / dots must never reach the filesystem + # lookup or the setup command path. 404 = rejected by the name guard; + # 405 = the router collapsed the dotted path onto a different route + # (equally safe — the handler never ran). + for bad in ("..", "..%2f..%2fetc", "a.b", "x/y", ".hidden", ""): + resp = self.client.get(f"/api/memory/providers/{bad}/config") + assert resp.status_code in (404, 405), (bad, resp.status_code) + resp = self.client.post( + f"/api/memory/providers/{bad}/setup", json={"values": {}} + ) + assert resp.status_code in (404, 405), (bad, resp.status_code) + resp = self.client.put( + f"/api/memory/providers/{bad}/config", json={"values": {}} + ) + assert resp.status_code in (404, 405), (bad, resp.status_code) + def test_post_memory_provider_setup_persists_values_without_activation(self): from hermes_cli.config import load_config, load_env From 75de0057bcb6f12289edf678599a7b3117cfa711 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Wed, 8 Jul 2026 10:34:34 +1000 Subject: [PATCH 185/610] feat(gateway): GATEWAY_MULTIPLEX_PROFILES env override for multiplex flag (#60589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connector now depends on the single multiplexed gateway for per-profile relay routing, so hosted deployments need to FORCE multiplexing on regardless of the image's config.yaml. gateway.multiplex_profiles was config.yaml-only, which a user could leave unset or flip off. Add GATEWAY_MULTIPLEX_PROFILES as a standard operator override on top of the existing config key — the same 'config.yaml is canonical, env is the operator override' pattern the Telegram/Signal require_mention bridges use: env (recognized token) > config.yaml (top-level or nested gateway.*) > False - gateway/config.py: _env_multiplex_profiles_override() resolves the env var tri-state — recognized truthy/falsy token → bool; unset/blank/unrecognized → None (fall through to config). Blank is deliberately None, not False, so a provisioned-but-unpopulated Fly secret ('') can't shadow a config.yaml opt-in (the empty-secret trap). Wired into GatewayConfig.from_dict so every consumer (run.py, session.py via self.config) sees the resolved value. - hermes_cli/gateway.py: the named-profile-start guard (_guard_named_profile_under_multiplexer) reads config.yaml directly, so it gets the SAME env precedence — otherwise env-forced multiplex would leave the guard blind and someone could start a conflicting per-profile gateway that double-binds a bot token. Env-forced-on trips the guard even with no config.yaml key; env-forced-off disables it over a config opt-in. Tests: full 3-tier precedence in test_config.py (incl. the discriminating env-overrides-config cases + the empty/whitespace/unrecognized fall-through trap + resolver tri-state), mutation-verified (flipping precedence fails exactly the two env-wins tests); guard env cases in test_multiplex_lifecycle.py. Force-on is safe on a single-profile instance: session keys stay byte-identical (agent:main) and the _run_agent wrapper installs the per-turn secret scope, so the fail-closed get_secret() path is satisfied. --- gateway/config.py | 49 ++++++++++++ hermes_cli/gateway.py | 33 +++++--- tests/gateway/test_config.py | 93 +++++++++++++++++++++++ tests/gateway/test_multiplex_lifecycle.py | 49 ++++++++++++ 4 files changed, 214 insertions(+), 10 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 63b321d9b659..8c467196e3b5 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -37,6 +37,43 @@ def _coerce_bool(value: Any, default: bool = True) -> bool: return is_truthy_value(value, default=default) +# Recognized truthy / falsy tokens for the GATEWAY_MULTIPLEX_PROFILES operator +# override. Anything not in either set — and a blank/whitespace value — is +# treated as "unset" so it falls through to config.yaml rather than silently +# forcing the flag off. +_MULTIPLEX_TRUTHY_STRINGS = frozenset({"1", "true", "yes", "on"}) +_MULTIPLEX_FALSY_STRINGS = frozenset({"0", "false", "no", "off"}) + + +def _env_multiplex_profiles_override() -> "bool | None": + """Resolve the GATEWAY_MULTIPLEX_PROFILES operator override. + + Returns ``True``/``False`` when the env var is set to a recognized truthy/ + falsy token, or ``None`` when it is unset, blank, or unrecognized — in which + case the caller keeps the config.yaml value (env > config > default). Blank + is deliberately ``None``, not ``False``: a provisioned-but-unpopulated Fly + secret arrives as ``""`` and must NOT shadow a config.yaml opt-in. + """ + raw = os.getenv("GATEWAY_MULTIPLEX_PROFILES") + if raw is None: + return None + token = raw.strip().lower() + if not token: + return None + if token in _MULTIPLEX_TRUTHY_STRINGS: + return True + if token in _MULTIPLEX_FALSY_STRINGS: + return False + logger.warning( + "Ignoring unrecognized GATEWAY_MULTIPLEX_PROFILES=%r " + "(expected one of %s or %s); falling back to config.yaml.", + raw, + sorted(_MULTIPLEX_TRUTHY_STRINGS), + sorted(_MULTIPLEX_FALSY_STRINGS), + ) + return None + + def _coerce_float(value: Any, default: float) -> float: """Coerce numeric config values, falling back on malformed input.""" if value is None: @@ -837,6 +874,18 @@ class GatewayConfig: # Also honor gateway.multiplex_profiles written by # ``hermes config set gateway.multiplex_profiles true``. multiplex_profiles = nested_gateway.get("multiplex_profiles") + # Operator override: GATEWAY_MULTIPLEX_PROFILES wins over config.yaml when + # set to a recognized value. Hosted deployments (Nous Portal / Fly) stamp + # it on the container so the single multiplexed gateway — which the + # connector now depends on for per-profile relay routing — is forced on at + # every boot regardless of the image's config.yaml, while self-hosted + # users keep setting gateway.multiplex_profiles in config.yaml. A blank or + # unrecognized env value falls through to config (the empty-secret trap: + # a provisioned-but-unpopulated Fly secret must not shadow config), so + # this is a genuine 3-tier chain: env > config.yaml > default False. + env_multiplex = _env_multiplex_profiles_override() + if env_multiplex is not None: + multiplex_profiles = env_multiplex if "max_concurrent_sessions" in data: max_concurrent_raw = data.get("max_concurrent_sessions") max_concurrent_key = "max_concurrent_sessions" diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index b04652de8fbb..ab743b281f19 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -4488,16 +4488,29 @@ def _guard_named_profile_under_multiplexer(force: bool = False) -> None: if not pid or not _pid_exists(pid): return - # (c) default config has multiplexing on - cfg_path = default_root / "config.yaml" - if not cfg_path.exists(): - return - with open(cfg_path, encoding="utf-8") as f: - cfg = _yaml.safe_load(f) or {} - multiplex = bool( - cfg.get("multiplex_profiles") - or (cfg.get("gateway", {}) or {}).get("multiplex_profiles") - ) + # (c) multiplexing is on for the default gateway. Precedence mirrors + # gateway.config: the GATEWAY_MULTIPLEX_PROFILES env override wins over + # config.yaml when set to a recognized value, so a hosted gateway that + # forces multiplex on via env (with no multiplex_profiles in config.yaml) + # still trips this guard. A blank/unrecognized env value falls through + # to config.yaml. + from gateway.config import _env_multiplex_profiles_override + + env_multiplex = _env_multiplex_profiles_override() + if env_multiplex is False: + return # explicitly forced OFF by the operator env override + if env_multiplex is True: + multiplex = True + else: + cfg_path = default_root / "config.yaml" + if not cfg_path.exists(): + return + with open(cfg_path, encoding="utf-8") as f: + cfg = _yaml.safe_load(f) or {} + multiplex = bool( + cfg.get("multiplex_profiles") + or (cfg.get("gateway", {}) or {}).get("multiplex_profiles") + ) if not multiplex: return except Exception: diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index b9d4993b5f06..6dc246e83db4 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -1315,3 +1315,96 @@ class TestHomeChannelEnvOverrides: home = config.platforms[platform].home_channel assert home is not None, f"{platform.value}: home_channel should not be None" assert (home.chat_id, home.name) == expected, platform.value + + +class TestMultiplexProfilesEnvOverride: + """GATEWAY_MULTIPLEX_PROFILES env override — the 3-tier precedence chain. + + env (recognized token) > config.yaml (top-level or nested gateway.*) > + default False. A blank / unrecognized env value is treated as UNSET and + falls through to config (the empty-secret trap: a provisioned-but-empty Fly + secret arrives as "" and must not shadow a config.yaml opt-in). + """ + + def _load(self, tmp_path, monkeypatch, config_text=None): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir(exist_ok=True) + if config_text is not None: + (hermes_home / "config.yaml").write_text(config_text, encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + return load_gateway_config() + + # ── Tier 1: env wins ────────────────────────────────────────────────── + def test_env_true_forces_on_with_no_config(self, tmp_path, monkeypatch): + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "true") + config = self._load(tmp_path, monkeypatch, config_text=None) + assert config.multiplex_profiles is True + + def test_env_true_overrides_config_false(self, tmp_path, monkeypatch): + # THE discriminating test: env-set wins over an explicit config value. + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "1") + config = self._load( + tmp_path, monkeypatch, config_text="multiplex_profiles: false\n" + ) + assert config.multiplex_profiles is True + + def test_env_false_overrides_config_true(self, tmp_path, monkeypatch): + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "off") + config = self._load( + tmp_path, monkeypatch, config_text="multiplex_profiles: true\n" + ) + assert config.multiplex_profiles is False + + # ── Tier 2: config.yaml when env unset ──────────────────────────────── + def test_config_true_when_env_unset(self, tmp_path, monkeypatch): + monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False) + config = self._load( + tmp_path, monkeypatch, config_text="multiplex_profiles: true\n" + ) + assert config.multiplex_profiles is True + + # ── The empty / unrecognized env trap: fall through, don't force off ── + def test_empty_env_does_not_shadow_config_true(self, tmp_path, monkeypatch): + # Provisioned-but-unpopulated Fly secret arrives as "". It must NOT + # turn OFF a config.yaml opt-in. + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "") + config = self._load( + tmp_path, monkeypatch, config_text="multiplex_profiles: true\n" + ) + assert config.multiplex_profiles is True + + def test_whitespace_env_does_not_shadow_config_true(self, tmp_path, monkeypatch): + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", " ") + config = self._load( + tmp_path, monkeypatch, config_text="multiplex_profiles: true\n" + ) + assert config.multiplex_profiles is True + + def test_unrecognized_env_falls_through_to_config(self, tmp_path, monkeypatch): + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "maybe") + config = self._load( + tmp_path, monkeypatch, config_text="multiplex_profiles: true\n" + ) + assert config.multiplex_profiles is True + + # ── Tier 3: default False ───────────────────────────────────────────── + def test_default_false_when_neither_set(self, tmp_path, monkeypatch): + monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False) + config = self._load(tmp_path, monkeypatch, config_text=None) + assert config.multiplex_profiles is False + + # ── The resolver in isolation ───────────────────────────────────────── + def test_resolver_tristate(self, monkeypatch): + from gateway.config import _env_multiplex_profiles_override + + monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False) + assert _env_multiplex_profiles_override() is None + for truthy in ("1", "true", "TRUE", "yes", "on"): + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", truthy) + assert _env_multiplex_profiles_override() is True, truthy + for falsy in ("0", "false", "FALSE", "no", "off"): + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", falsy) + assert _env_multiplex_profiles_override() is False, falsy + for noise in ("", " ", "maybe", "2"): + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", noise) + assert _env_multiplex_profiles_override() is None, repr(noise) diff --git a/tests/gateway/test_multiplex_lifecycle.py b/tests/gateway/test_multiplex_lifecycle.py index 6b5da5d9c380..cb4b9763e5cb 100644 --- a/tests/gateway/test_multiplex_lifecycle.py +++ b/tests/gateway/test_multiplex_lifecycle.py @@ -53,3 +53,52 @@ class TestNamedProfileMultiplexerGuard: ) # No gateway.pid in tmp_path => no running default gateway => no raise. gw._guard_named_profile_under_multiplexer(force=False) + + def _fake_running_default_gateway(self, monkeypatch, tmp_path): + """Make the guard believe a live default gateway exists at tmp_path.""" + from hermes_cli import gateway as gw + import gateway.status as status + + monkeypatch.setattr(gw, "_profile_suffix", lambda: "coder") + monkeypatch.setattr( + "hermes_constants.get_default_hermes_root", lambda: tmp_path + ) + (tmp_path / "gateway.pid").write_text("12345", encoding="utf-8") + monkeypatch.setattr(status, "_read_pid_record", lambda p: {"pid": 12345}) + monkeypatch.setattr(status, "_pid_from_record", lambda rec: 12345) + monkeypatch.setattr(status, "_pid_exists", lambda pid: True) + + def test_env_forces_guard_even_without_config(self, monkeypatch, tmp_path): + """GATEWAY_MULTIPLEX_PROFILES=true must trip the guard even when the + default profile's config.yaml has no multiplex_profiles key — the hosted + case where multiplex is forced purely by the env stamp.""" + from hermes_cli import gateway as gw + self._fake_running_default_gateway(monkeypatch, tmp_path) + # No config.yaml written → the only signal is the env override. + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "true") + with pytest.raises(SystemExit): + gw._guard_named_profile_under_multiplexer(force=False) + + def test_env_false_disables_guard_over_config_true(self, monkeypatch, tmp_path): + """GATEWAY_MULTIPLEX_PROFILES=false wins over a config.yaml opt-in, so + the guard stays inert (symmetric with the config precedence).""" + from hermes_cli import gateway as gw + self._fake_running_default_gateway(monkeypatch, tmp_path) + (tmp_path / "config.yaml").write_text( + "multiplex_profiles: true\n", encoding="utf-8" + ) + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "false") + # Env forces OFF → guard must NOT raise. + gw._guard_named_profile_under_multiplexer(force=False) + + def test_blank_env_falls_through_to_config_and_raises(self, monkeypatch, tmp_path): + """A blank env value must not shadow a config.yaml opt-in: the guard + still trips on the config value.""" + from hermes_cli import gateway as gw + self._fake_running_default_gateway(monkeypatch, tmp_path) + (tmp_path / "config.yaml").write_text( + "multiplex_profiles: true\n", encoding="utf-8" + ) + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "") + with pytest.raises(SystemExit): + gw._guard_named_profile_under_multiplexer(force=False) From 543f069093ba7f2c763f4e22139e93a275f364ec Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Mon, 22 Jun 2026 13:26:47 +1000 Subject: [PATCH 186/610] Fix dashboard chat model profile scoping --- hermes_cli/web_server.py | 125 +++++++++++++------------ tests/hermes_cli/test_web_server.py | 35 +++++++ web/src/components/ChatSidebar.tsx | 30 +++--- web/src/components/ReasoningPicker.tsx | 20 ++-- web/src/lib/api.ts | 49 ++++++---- web/src/pages/ChatPage.tsx | 4 +- 6 files changed, 162 insertions(+), 101 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index e45d47848fb8..4e1582fdeb96 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -8675,14 +8675,12 @@ async def cancel_oauth_session( -def _session_latest_descendant(session_id: str): +def _session_latest_descendant(session_id: str, db): """Resolve a session id to the newest child leaf session. /model may create child sessions. Dashboard refresh should continue the newest child instead of reopening the old parent. """ - from hermes_state import SessionDB - def row_get(row, key, index): if isinstance(row, dict): return row.get(key) @@ -8694,62 +8692,58 @@ def _session_latest_descendant(session_id: str): except Exception: return None - db = SessionDB() - try: - sid = db.resolve_session_id(session_id) - if not sid or not db.get_session(sid): - return None, [] + sid = db.resolve_session_id(session_id) + if not sid or not db.get_session(sid): + return None, [] - conn = ( - getattr(db, "conn", None) - or getattr(db, "_conn", None) - or getattr(db, "connection", None) - or getattr(db, "_connection", None) - ) + conn = ( + getattr(db, "conn", None) + or getattr(db, "_conn", None) + or getattr(db, "connection", None) + or getattr(db, "_connection", None) + ) - rows = [] - if conn is not None: - raw_rows = conn.execute( - "SELECT id, parent_session_id, started_at FROM sessions" - ).fetchall() - for row in raw_rows: - rows.append({ - "id": row_get(row, "id", 0), - "parent_session_id": row_get(row, "parent_session_id", 1), - "started_at": row_get(row, "started_at", 2), - }) - else: - rows = db.list_sessions_rich(limit=10000, offset=0) + rows = [] + if conn is not None: + raw_rows = conn.execute( + "SELECT id, parent_session_id, started_at FROM sessions" + ).fetchall() + for row in raw_rows: + rows.append({ + "id": row_get(row, "id", 0), + "parent_session_id": row_get(row, "parent_session_id", 1), + "started_at": row_get(row, "started_at", 2), + }) + else: + rows = db.list_sessions_rich(limit=10000, offset=0) - children = {} - for row in rows: - rid = row.get("id") - parent = row.get("parent_session_id") - if rid and parent: - children.setdefault(parent, []).append(row) + children = {} + for row in rows: + rid = row.get("id") + parent = row.get("parent_session_id") + if rid and parent: + children.setdefault(parent, []).append(row) - def started(row): - try: - return float(row.get("started_at") or 0) - except Exception: - return 0.0 + def started(row): + try: + return float(row.get("started_at") or 0) + except Exception: + return 0.0 - current = sid - path = [sid] - seen = {sid} + current = sid + path = [sid] + seen = {sid} - while children.get(current): - candidates = [r for r in children[current] if r.get("id") not in seen] - if not candidates: - break - candidates.sort(key=started, reverse=True) - current = candidates[0]["id"] - path.append(current) - seen.add(current) + while children.get(current): + candidates = [r for r in children[current] if r.get("id") not in seen] + if not candidates: + break + candidates.sort(key=started, reverse=True) + current = candidates[0]["id"] + path.append(current) + seen.add(current) - return current, path - finally: - db.close() + return current, path # CRITICAL — every literal-path route below MUST be declared BEFORE the @@ -8923,16 +8917,23 @@ async def get_session_detail(session_id: str, profile: Optional[str] = None): @app.get("/api/sessions/{session_id}/latest-descendant") -async def get_session_latest_descendant(session_id: str): - latest, path = _session_latest_descendant(session_id) - if not latest: - raise HTTPException(status_code=404, detail="Session not found") - return { - "requested_session_id": path[0] if path else session_id, - "session_id": latest, - "path": path, - "changed": bool(path and latest != path[0]), - } +async def get_session_latest_descendant( + session_id: str, + profile: Optional[str] = None, +): + db = _open_session_db_for_profile(profile) + try: + latest, path = _session_latest_descendant(session_id, db) + if not latest: + raise HTTPException(status_code=404, detail="Session not found") + return { + "requested_session_id": path[0] if path else session_id, + "session_id": latest, + "path": path, + "changed": bool(path and latest != path[0]), + } + finally: + db.close() @app.get("/api/sessions/{session_id}/messages") async def get_session_messages(session_id: str, profile: Optional[str] = None): diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 035dddffe4c4..ae732bcaba26 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1049,6 +1049,41 @@ class TestWebServerEndpoints: messages = self.client.get("/api/sessions/worker-only/messages?profile=worker").json() assert [m["content"] for m in messages["messages"]] == ["worker"] + def test_latest_descendant_reads_requested_profile(self): + """Chat resume must resolve compression tips in the chat profile DB.""" + from hermes_state import SessionDB + from hermes_cli import profiles as profiles_mod + + worker_home = profiles_mod.get_profile_dir("worker") + worker_home.mkdir(parents=True) + + default_db = SessionDB() + try: + default_db.create_session(session_id="shared-root", source="cli") + finally: + default_db.close() + + worker_db = SessionDB(db_path=worker_home / "state.db") + try: + worker_db.create_session(session_id="shared-root", source="cli") + worker_db.create_session( + session_id="worker-tip", + source="cli", + parent_session_id="shared-root", + ) + finally: + worker_db.close() + + default_resp = self.client.get("/api/sessions/shared-root/latest-descendant") + assert default_resp.status_code == 200 + assert default_resp.json()["session_id"] == "shared-root" + + worker_resp = self.client.get( + "/api/sessions/shared-root/latest-descendant?profile=worker" + ) + assert worker_resp.status_code == 200 + assert worker_resp.json()["session_id"] == "worker-tip" + def test_analytics_endpoints_read_requested_profile(self): from hermes_state import SessionDB from hermes_cli import profiles as profiles_mod diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 0c3286f528e7..47cb5e72b052 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -72,7 +72,7 @@ const STATE_TONE: Record< interface ChatSidebarProps { channel: string; - /** Management profile from the dashboard switcher — scopes session.create. */ + /** Chat profile from the dashboard switcher / URL scope. */ profile?: string; className?: string; onDashboardNewSessionRequest?: () => void; @@ -103,8 +103,9 @@ export function ChatSidebar({ // session boots from. We deliberately don't use the sidecar's `session.info` // model: that's a one-time snapshot of the throwaway sidecar agent taken when // its session is created, and it never updates when the model is changed - // elsewhere, so the badge would go stale. `/api/model/info` is profile-scoped - // by `fetchJSON`, so it reads the same profile this sidebar is scoped to. + // elsewhere, so the badge would go stale. Pass the chat profile explicitly so + // this card stays scoped to the PTY even if the global dashboard switcher + // changes while the chat is open. const [effectiveModel, setEffectiveModel] = useState(""); // Whether the effective model supports reasoning effort — gates the // ReasoningPicker. Read from the same `/api/model/info` capabilities the @@ -125,7 +126,7 @@ export function ChatSidebar({ const refreshEffectiveModel = useCallback(() => { void api - .getModelInfo() + .getModelInfo(profile) .then((r) => { if (r?.model) setEffectiveModel(String(r.model)); setSupportsReasoning(!!r?.capabilities?.supports_reasoning); @@ -135,7 +136,7 @@ export function ChatSidebar({ .catch(() => { // Best-effort: keep the last known label rather than blanking it. }); - }, []); + }, [profile]); // Profile or PTY channel change tears down both WebSockets. Bump `version` // (same path as the manual Reconnect button) so the gateway client is @@ -208,6 +209,7 @@ export function ChatSidebar({ gw.close(); }; // `profile` is read from render; scope changes bump `version` → new `gw`. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [gw]); // Event subscriber WebSocket — receives the rebroadcast of every @@ -344,6 +346,7 @@ export function ChatSidebar({ setModelNotice( @@ -391,17 +394,20 @@ export function ChatSidebar({ // Same path the Models page uses (REST /api/model/set), not the // sidecar config.set RPC, which didn't reliably land in the // config.yaml the agent boots from. Always persisted (alwaysGlobal). - loader={api.getModelOptions} + loader={() => api.getModelOptions(profile)} alwaysGlobal onApply={async ({ provider, model, confirmExpensiveModel }) => { setModelNotice(null); setPendingReloadModel(null); - const result = await api.setModelAssignment({ - confirm_expensive_model: confirmExpensiveModel, - scope: "main", - provider, - model, - }); + const result = await api.setModelAssignment( + { + confirm_expensive_model: confirmExpensiveModel, + scope: "main", + provider, + model, + }, + profile, + ); // confirm_required => the dialog shows the expensive-model prompt // and calls back; don't announce until the user confirms. if (!result.confirm_required) { diff --git a/web/src/components/ReasoningPicker.tsx b/web/src/components/ReasoningPicker.tsx index 77ef2e35bdd1..cd45986a766c 100644 --- a/web/src/components/ReasoningPicker.tsx +++ b/web/src/components/ReasoningPicker.tsx @@ -15,9 +15,8 @@ * running chat session adopts the change on the next `/new` or page reload; * we surface that hint rather than forcing a reload here. * - * Profile scoping: `/api/config` is profile-scoped by `fetchJSON` via the - * global management profile — the same scope the sidebar's `/api/model/info` - * badge reads from — so this writes the profile the sidebar is showing. + * Profile scoping: the sidebar passes the chat profile explicitly, so this + * reads/writes the same config the chat PTY was launched from. */ import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; @@ -35,6 +34,8 @@ interface ReasoningPickerProps { /** Current model string from config — re-reads the saved effort when it * changes (a different model may have been selected). */ currentModel: string; + /** Profile whose config should be read/written. */ + profile?: string; /** Bumped after the model picker saves, to re-read config in lockstep. */ refreshKey?: number; /** Called after a successful change so the sidebar can show an "apply on @@ -44,6 +45,7 @@ interface ReasoningPickerProps { export function ReasoningPicker({ currentModel, + profile, refreshKey = 0, onChanged, }: ReasoningPickerProps) { @@ -53,11 +55,11 @@ export function ReasoningPicker({ const lastFetchKeyRef = useRef(""); useEffect(() => { - const fetchKey = `${currentModel}:${refreshKey}`; + const fetchKey = `${profile ?? ""}:${currentModel}:${refreshKey}`; if (fetchKey === lastFetchKeyRef.current) return; lastFetchKeyRef.current = fetchKey; void api - .getConfig() + .getConfig(profile) .then((cfg) => { const agent = (cfg?.agent as Record | undefined) ?? {}; setEffort(normalizeEffort(agent.reasoning_effort)); @@ -67,7 +69,7 @@ export function ReasoningPicker({ // Best-effort: keep the last known value rather than blanking it. setLoaded(true); }); - }, [currentModel, refreshKey]); + }, [currentModel, profile, refreshKey]); const onSelect = useCallback( (next: string) => { @@ -79,7 +81,7 @@ export function ReasoningPicker({ // pattern — so we never clobber sibling keys. `saveConfig` PUTs the full // object the agent boots from. void api - .getConfig() + .getConfig(profile) .then((cfg) => { const base = (cfg ?? {}) as Record; const agent = @@ -87,7 +89,7 @@ export function ReasoningPicker({ ? { ...(base.agent as Record) } : {}; agent.reasoning_effort = next; - return api.saveConfig({ ...base, agent }); + return api.saveConfig({ ...base, agent }, profile); }) .then(() => { onChanged?.(next); @@ -97,7 +99,7 @@ export function ReasoningPicker({ }) .finally(() => setSaving(false)); }, - [effort, onChanged], + [effort, onChanged, profile], ); return ( diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 5dc982430a9a..8aa443828e4e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -369,9 +369,12 @@ export const api = { fetchJSON( appendProfileParam(`/api/sessions/${encodeURIComponent(id)}`, profile), ), - getSessionLatestDescendant: (id: string) => + getSessionLatestDescendant: (id: string, profile = getManagementProfile()) => fetchJSON( - `/api/sessions/${encodeURIComponent(id)}/latest-descendant`, + appendProfileParam( + `/api/sessions/${encodeURIComponent(id)}/latest-descendant`, + profile, + ), ), deleteSession: (id: string, profile = getManagementProfile()) => fetchJSON<{ ok: boolean }>( @@ -471,10 +474,12 @@ export const api = { fetchJSON( appendProfileParam(`/api/analytics/models?days=${days}`, profile), ), - getConfig: () => fetchJSON>("/api/config"), + getConfig: (profile = getManagementProfile()) => + fetchJSON>(appendProfileParam("/api/config", profile)), getDefaults: () => fetchJSON>("/api/config/defaults"), getSchema: () => fetchJSON<{ fields: Record; category_order: string[] }>("/api/config/schema"), - getModelInfo: () => fetchJSON("/api/model/info"), + getModelInfo: (profile = getManagementProfile()) => + fetchJSON(appendProfileParam("/api/model/info", profile)), getModelOptions: ( profileOrOptions?: string | { profile?: string; refresh?: boolean }, ) => { @@ -495,7 +500,10 @@ export const api = { const suffix = qs.toString() ? `?${qs.toString()}` : ""; return fetchJSON(`/api/model/options${suffix}`); }, - getAuxiliaryModels: () => fetchJSON("/api/model/auxiliary"), + getAuxiliaryModels: (profile = getManagementProfile()) => + fetchJSON( + appendProfileParam("/api/model/auxiliary", profile), + ), getMoaModels: () => fetchJSON("/api/model/moa"), saveMoaModels: (body: MoaConfigResponse) => fetchJSON("/api/model/moa", { @@ -503,21 +511,30 @@ export const api = { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }), - setModelAssignment: (body: ModelAssignmentRequest) => - fetchJSON("/api/model/set", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }), - saveConfig: (config: Record) => - fetchJSON<{ ok: boolean }>("/api/config", { + setModelAssignment: ( + body: ModelAssignmentRequest, + profile = getManagementProfile(), + ) => + fetchJSON( + appendProfileParam("/api/model/set", profile), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + saveConfig: (config: Record, profile = getManagementProfile()) => + fetchJSON<{ ok: boolean }>(appendProfileParam("/api/config", profile), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ config }), }), - getConfigRaw: () => fetchJSON<{ yaml: string; path?: string }>("/api/config/raw"), - saveConfigRaw: (yaml_text: string) => - fetchJSON<{ ok: boolean }>("/api/config/raw", { + getConfigRaw: (profile = getManagementProfile()) => + fetchJSON<{ yaml: string; path?: string }>( + appendProfileParam("/api/config/raw", profile), + ), + saveConfigRaw: (yaml_text: string, profile = getManagementProfile()) => + fetchJSON<{ ok: boolean }>(appendProfileParam("/api/config/raw", profile), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ yaml_text }), diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 9ca904a96041..708b9df88c22 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -292,7 +292,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { let cancelled = false; api - .getSessionLatestDescendant(resumeParam) + .getSessionLatestDescendant(resumeParam, scopedProfile) .then((res) => { if (cancelled || !res.session_id || res.session_id === resumeParam) { return; @@ -309,7 +309,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { return () => { cancelled = true; }; - }, [resumeParam, searchParams, setSearchParams]); + }, [resumeParam, scopedProfile, searchParams, setSearchParams]); useEffect(() => { const mql = window.matchMedia("(max-width: 1023px)"); From 6015ee5d2add69e69c6964147b3fe35fd6d1b643 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:36:05 -0700 Subject: [PATCH 187/610] fix: pass profile-scoped SessionDB to _session_latest_descendant in dashboard chat PTY resume The chat PTY launch path landed on main after PR #50558 and still called _session_latest_descendant() with the old one-arg signature. Open the requested profile's state DB (matching the REST endpoint) so profile-scoped resume resolves descendants in the right database. --- hermes_cli/web_server.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 4e1582fdeb96..64f1e684e705 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -13757,7 +13757,13 @@ def _resolve_chat_argv( env["HERMES_HOME"] = str(profile_dir) if resume: - latest_resume, _latest_path = _session_latest_descendant(resume) + _resume_db = _open_session_db_for_profile( + requested if profile_dir is not None else None + ) + try: + latest_resume, _latest_path = _session_latest_descendant(resume, _resume_db) + finally: + _resume_db.close() if latest_resume: resume = latest_resume env["HERMES_TUI_RESUME"] = resume From 4f620a0bbc11f41f241145a8dad3f70989507559 Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Thu, 2 Jul 2026 12:28:26 +1000 Subject: [PATCH 188/610] Add WhatsApp dashboard pairing flow --- hermes_cli/web_server.py | 535 ++++++++++++++++++- plugins/platforms/whatsapp/adapter.py | 8 +- scripts/whatsapp-bridge/bridge.js | 163 ++++-- tests/hermes_cli/test_whatsapp_onboarding.py | 323 +++++++++++ web/src/lib/api.ts | 71 +++ web/src/pages/ChannelsPage.tsx | 426 +++++++++++++++ 6 files changed, 1485 insertions(+), 41 deletions(-) create mode 100644 tests/hermes_cli/test_whatsapp_onboarding.py diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 64f1e684e705..30c86e9e90a5 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -873,6 +873,18 @@ class TelegramOnboardingApply(BaseModel): profile: Optional[str] = None +class WhatsAppOnboardingStart(BaseModel): + mode: Optional[str] = "bot" + allowed_users: Optional[str] = "" + profile: Optional[str] = None + + +class WhatsAppOnboardingApply(BaseModel): + mode: Optional[str] = None + allowed_users: Optional[str] = None + profile: Optional[str] = None + + class AudioTranscriptionRequest(BaseModel): data_url: str mime_type: Optional[str] = None @@ -6111,7 +6123,12 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = { "name": "WhatsApp", "description": "Use Hermes through the bundled WhatsApp bridge with QR-based auth.", "docs_url": "https://github.com/tulir/whatsmeow", - "env_vars": ("WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS"), + "env_vars": ( + "WHATSAPP_ENABLED", + "WHATSAPP_MODE", + "WHATSAPP_DM_POLICY", + "WHATSAPP_ALLOWED_USERS", + ), "required_env": (), }, "homeassistant": { @@ -6305,6 +6322,11 @@ _MESSAGING_ENV_FALLBACKS: dict[str, dict[str, Any]] = { "prompt": "WhatsApp mode", "advanced": True, }, + "WHATSAPP_DM_POLICY": { + "description": "How WhatsApp direct messages are authorized", + "prompt": "WhatsApp DM policy", + "advanced": True, + }, "WHATSAPP_ALLOWED_USERS": { "description": "Comma-separated WhatsApp users allowed to use the bot", "prompt": "Allowed WhatsApp users", @@ -6701,7 +6723,23 @@ def _messaging_platform_payload( error_code = error_code or "startup_failed" error_message = error_message or runtime_gateway_error - return { + whatsapp_setup = None + if platform_id == "whatsapp": + whatsapp_mode = ( + env_on_disk.get("WHATSAPP_MODE") + or ("" if scoped else os.getenv("WHATSAPP_MODE", "")) + ).strip() + allowed_users_value = ( + env_on_disk.get("WHATSAPP_ALLOWED_USERS") + or ("" if scoped else os.getenv("WHATSAPP_ALLOWED_USERS", "")) + ).strip() + whatsapp_setup = { + "mode": whatsapp_mode if whatsapp_mode in {"bot", "self-chat"} else "", + "allowed_users_set": bool(allowed_users_value), + "home_channel_set": bool(home_channel), + } + + payload = { "id": platform_id, "name": entry["name"], "description": entry["description"], @@ -6720,12 +6758,505 @@ def _messaging_platform_payload( "home_channel": home_channel, "env_vars": env_vars, } + if whatsapp_setup is not None: + payload["whatsapp_setup"] = whatsapp_setup + return payload def _write_platform_enabled(platform_id: str, enabled: bool) -> None: write_platform_config_field(platform_id, "enabled", enabled) +_WHATSAPP_ONBOARDING_TTL_SECONDS = 600 +_WHATSAPP_ONBOARDING_TERMINAL_STATUSES = {"connected", "error", "expired", "cancelled"} + + +@dataclass +class _WhatsAppOnboardingSession: + proc: subprocess.Popen | None + mode: str + allowed_users: str + session_path: str + expires_at: str + expires_at_ts: float + profile: str | None = None + status: str = "starting" + qr_payload: str | None = None + account_id: str | None = None + account_name: str | None = None + account_phone: str | None = None + error: str | None = None + + +_whatsapp_onboarding_sessions: dict[str, _WhatsAppOnboardingSession] = {} +_whatsapp_onboarding_lock = threading.RLock() + + +def _utc_iso_from_ts(ts: float) -> str: + return datetime.fromtimestamp(ts, timezone.utc).isoformat().replace("+00:00", "Z") + + +def _normalize_whatsapp_onboarding_mode(value: Any) -> str: + mode = str(value or "bot").strip().lower() + if mode not in {"bot", "self-chat"}: + raise HTTPException(status_code=400, detail="WhatsApp mode must be 'bot' or 'self-chat'.") + return mode + + +def _normalize_whatsapp_allowed_users(value: Any) -> str: + raw = str(value or "").strip() + if not raw: + return "" + return ",".join(part.replace(" ", "") for part in raw.split(",") if part.strip()) + + +def _whatsapp_session_path() -> Path: + from hermes_constants import get_hermes_dir + + return get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") + + +def _whatsapp_phone_from_identifier(value: Any) -> str | None: + raw = str(value or "").strip() + if not raw: + return None + candidate = raw.split("@", 1)[0].split(":", 1)[0] + digits = re.sub(r"\D+", "", candidate) + return digits or None + + +def _whatsapp_linked_account_from_session(session_path: Path) -> tuple[str | None, str | None, str | None]: + creds_path = session_path / "creds.json" + try: + payload = json.loads(creds_path.read_text(encoding="utf-8")) + except Exception: + return None, None, None + + account_id: str | None = None + account_name: str | None = None + + def collect(candidate: Any) -> None: + nonlocal account_id, account_name + if not isinstance(candidate, dict): + return + if account_id is None: + for key in ("id", "jid", "lid"): + value = str(candidate.get(key) or "").strip() + if value: + account_id = value + break + if account_name is None: + for key in ("name", "verifiedName", "notify", "pushName"): + value = str(candidate.get(key) or "").strip() + if value: + account_name = value + break + + collect(payload.get("me")) + collect(payload.get("account")) + collect(payload) + return account_id, account_name, _whatsapp_phone_from_identifier(account_id) + + +def _ensure_whatsapp_bridge_dependencies(bridge_dir: Path) -> None: + """Install bridge dependencies when the dashboard is the setup surface.""" + if (bridge_dir / "node_modules").exists(): + return + + from hermes_constants import find_node_executable, with_hermes_node_path + from utils import env_int + + npm = find_node_executable("npm") + if not npm: + raise HTTPException( + status_code=500, + detail="npm was not found. WhatsApp setup needs Node.js and npm.", + ) + + timeout = env_int("WHATSAPP_NPM_INSTALL_TIMEOUT", 300) + try: + result = subprocess.run( + [npm, "install", "--silent"], + cwd=str(bridge_dir), + capture_output=True, + text=True, + timeout=timeout, + env=with_hermes_node_path(), + creationflags=windows_hide_flags(), + ) + except subprocess.TimeoutExpired as exc: + raise HTTPException( + status_code=500, + detail="Installing WhatsApp bridge dependencies timed out.", + ) from exc + except OSError as exc: + raise HTTPException( + status_code=500, + detail=f"Failed to install WhatsApp bridge dependencies: {exc}", + ) from exc + + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() + if detail: + detail = "\n".join(detail.splitlines()[-10:]) + raise HTTPException( + status_code=500, + detail=f"npm install failed for WhatsApp bridge: {detail or 'no output'}", + ) + + +def _spawn_whatsapp_pairing_process(session_path: Path, mode: str) -> subprocess.Popen: + from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir + from hermes_constants import find_node_executable, with_hermes_node_path + + bridge_dir = resolve_whatsapp_bridge_dir() + bridge_script = bridge_dir / "bridge.js" + if not bridge_script.exists(): + raise HTTPException( + status_code=500, + detail=f"WhatsApp bridge script was not found at {bridge_script}.", + ) + node = find_node_executable("node") + if not node: + raise HTTPException( + status_code=500, + detail="Node.js was not found. WhatsApp setup needs Node.js.", + ) + + _ensure_whatsapp_bridge_dependencies(bridge_dir) + session_path.mkdir(parents=True, exist_ok=True) + + env = with_hermes_node_path() + env["WHATSAPP_MODE"] = mode + env["WHATSAPP_DM_POLICY"] = "pairing" + return subprocess.Popen( + [ + node, + str(bridge_script), + "--pair-only", + "--pair-json", + "--session", + str(session_path), + ], + cwd=str(bridge_dir), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + start_new_session=True, + env=env, + creationflags=windows_hide_flags(), + ) + + +def _terminate_whatsapp_pairing(proc: subprocess.Popen | None) -> None: + if proc is None: + return + if proc.poll() is not None: + return + try: + proc.terminate() + proc.wait(timeout=3) + except Exception: + try: + proc.kill() + except Exception: + pass + + +def _watch_whatsapp_pairing(pairing_id: str, proc: subprocess.Popen) -> None: + try: + stream = proc.stdout + if stream is not None: + for line in stream: + raw = line.strip() + if not raw: + continue + try: + payload = json.loads(raw) + except json.JSONDecodeError: + continue + event = str(payload.get("event") or "").strip() + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record or record.proc is not proc: + return + if event == "qr": + qr = str(payload.get("qr") or "").strip() + if qr: + record.qr_payload = qr + record.status = "waiting" + record.error = None + elif event == "connected": + user = payload.get("user") + if isinstance(user, dict): + account_id = str(user.get("id") or "").strip() + account_name = str(user.get("name") or "").strip() + record.account_id = account_id or None + record.account_name = account_name or None + record.account_phone = _whatsapp_phone_from_identifier(account_id) + record.status = "connected" + record.error = None + elif event == "error": + record.status = "error" + record.error = str(payload.get("error") or "WhatsApp pairing failed.") + elif event == "disconnected" and record.status == "starting": + record.status = "waiting" + returncode = proc.wait() + except Exception as exc: + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if record and record.proc is proc and record.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + record.status = "error" + record.error = str(exc) + return + + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record or record.proc is not proc: + return + if record.status in {"connected", "cancelled", "expired"}: + return + record.status = "error" + record.error = ( + "WhatsApp pairing process exited before pairing completed." + if returncode == 0 + else f"WhatsApp pairing process exited with code {returncode}." + ) + + +def _run_whatsapp_pairing(pairing_id: str, session_path: Path, mode: str) -> None: + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record or record.status in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + return + record.status = "installing" + + try: + proc = _spawn_whatsapp_pairing_process(session_path, mode) + except Exception as exc: + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if record and record.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + record.status = "error" + record.error = str(exc) + return + + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record or record.status in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + _terminate_whatsapp_pairing(proc) + return + record.proc = proc + record.status = "starting" + + _watch_whatsapp_pairing(pairing_id, proc) + + +def _prune_whatsapp_onboarding_sessions() -> None: + now = time.time() + remove_ids: list[str] = [] + for pairing_id, record in _whatsapp_onboarding_sessions.items(): + if ( + record.proc is not None + and record.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES + and record.proc.poll() is not None + ): + record.status = "error" + record.error = "WhatsApp pairing process exited before pairing completed." + if record.expires_at_ts <= now and record.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + _terminate_whatsapp_pairing(record.proc) + record.status = "expired" + record.error = "WhatsApp QR setup expired. Start a new setup." + if record.status in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES and record.expires_at_ts + 300 <= now: + remove_ids.append(pairing_id) + for pairing_id in remove_ids: + _whatsapp_onboarding_sessions.pop(pairing_id, None) + + +def _supersede_whatsapp_onboarding_sessions(session_path: Path) -> None: + for existing in _whatsapp_onboarding_sessions.values(): + if existing.session_path == str(session_path) and existing.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + existing.status = "cancelled" + existing.error = "Superseded by a newer WhatsApp setup session." + _terminate_whatsapp_pairing(existing.proc) + + +def _whatsapp_onboarding_payload(pairing_id: str, record: _WhatsAppOnboardingSession) -> dict[str, Any]: + return { + "pairing_id": pairing_id, + "status": record.status, + "qr_payload": record.qr_payload, + "expires_at": record.expires_at, + "mode": record.mode, + "allowed_users": record.allowed_users, + "account_id": record.account_id, + "account_name": record.account_name, + "account_phone": record.account_phone, + "error": record.error, + } + + +def _restart_gateway_after_whatsapp_onboarding(profile: Optional[str] = None) -> dict[str, Any]: + try: + proc, reused = _spawn_gateway_restart(profile) + except Exception as exc: + _log.exception("Failed to auto-restart gateway after WhatsApp onboarding") + return { + "restart_started": False, + "restart_error": str(exc), + } + if reused: + _log.info( + "WhatsApp onboarding: reusing in-flight gateway restart (pid %s)", + proc.pid, + ) + return { + "restart_started": True, + "restart_action": "gateway-restart", + "restart_pid": proc.pid, + } + + +@app.post("/api/messaging/whatsapp/onboarding/start") +async def start_whatsapp_onboarding(body: WhatsAppOnboardingStart): + mode = _normalize_whatsapp_onboarding_mode(body.mode) + allowed_users = _normalize_whatsapp_allowed_users(body.allowed_users) + effective_profile = body.profile + + with _config_profile_scope(effective_profile): + session_path = _whatsapp_session_path() + expires_at_ts = time.time() + _WHATSAPP_ONBOARDING_TTL_SECONDS + expires_at = _utc_iso_from_ts(expires_at_ts) + if (session_path / "creds.json").exists(): + pairing_id = secrets.token_urlsafe(16) + account_id, account_name, account_phone = _whatsapp_linked_account_from_session(session_path) + record = _WhatsAppOnboardingSession( + proc=None, + mode=mode, + allowed_users=allowed_users, + session_path=str(session_path), + expires_at=expires_at, + expires_at_ts=expires_at_ts, + profile=effective_profile, + status="connected", + account_id=account_id, + account_name=account_name, + account_phone=account_phone, + ) + with _whatsapp_onboarding_lock: + _prune_whatsapp_onboarding_sessions() + _supersede_whatsapp_onboarding_sessions(session_path) + _whatsapp_onboarding_sessions[pairing_id] = record + return _whatsapp_onboarding_payload(pairing_id, record) + + pairing_id = secrets.token_urlsafe(16) + record = _WhatsAppOnboardingSession( + proc=None, + mode=mode, + allowed_users=allowed_users, + session_path=str(session_path), + expires_at=expires_at, + expires_at_ts=expires_at_ts, + profile=effective_profile, + ) + + with _whatsapp_onboarding_lock: + _prune_whatsapp_onboarding_sessions() + _supersede_whatsapp_onboarding_sessions(session_path) + _whatsapp_onboarding_sessions[pairing_id] = record + + threading.Thread( + target=_run_whatsapp_pairing, + args=(pairing_id, session_path, mode), + daemon=True, + ).start() + + return _whatsapp_onboarding_payload(pairing_id, record) + + +@app.get("/api/messaging/whatsapp/onboarding/{pairing_id}") +async def get_whatsapp_onboarding_status(pairing_id: str): + with _whatsapp_onboarding_lock: + _prune_whatsapp_onboarding_sessions() + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record: + raise HTTPException( + status_code=404, + detail="WhatsApp setup session was not found. Start a new setup.", + ) + if record.status == "expired": + raise HTTPException(status_code=410, detail=record.error or "WhatsApp setup expired.") + return _whatsapp_onboarding_payload(pairing_id, record) + + +@app.post("/api/messaging/whatsapp/onboarding/{pairing_id}/apply") +async def apply_whatsapp_onboarding( + pairing_id: str, body: WhatsAppOnboardingApply, profile: Optional[str] = None +): + with _whatsapp_onboarding_lock: + _prune_whatsapp_onboarding_sessions() + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record: + raise HTTPException( + status_code=404, + detail="WhatsApp setup session was not found. Start a new setup.", + ) + if record.status != "connected": + raise HTTPException(status_code=409, detail="WhatsApp setup is not connected yet.") + mode = _normalize_whatsapp_onboarding_mode(body.mode or record.mode) + allowed_users = _normalize_whatsapp_allowed_users( + record.allowed_users if body.allowed_users is None else body.allowed_users + ) + if mode == "self-chat" and not allowed_users: + allowed_users = record.account_phone or record.account_id or "" + record_profile = record.profile + + effective_profile = body.profile or profile or record_profile + try: + with _config_profile_scope(effective_profile): + save_env_value("WHATSAPP_MODE", mode) + save_env_value("WHATSAPP_DM_POLICY", "pairing") + if allowed_users: + save_env_value("WHATSAPP_ALLOWED_USERS", allowed_users) + # Blank means "keep the existing allowlist"; explicit clearing + # still lives in the normal config editor where the field is visible. + save_env_value("WHATSAPP_ENABLED", "true") + _write_platform_enabled("whatsapp", True) + except HTTPException: + raise + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + _log.exception("WhatsApp onboarding apply failed") + raise HTTPException( + status_code=500, + detail="Failed to save WhatsApp setup.", + ) from exc + + with _whatsapp_onboarding_lock: + _whatsapp_onboarding_sessions.pop(pairing_id, None) + + restart_result = _restart_gateway_after_whatsapp_onboarding(effective_profile) + return { + "ok": True, + "platform": "whatsapp", + "needs_restart": not restart_result["restart_started"], + **restart_result, + } + + +@app.delete("/api/messaging/whatsapp/onboarding/{pairing_id}") +async def cancel_whatsapp_onboarding(pairing_id: str): + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.pop(pairing_id, None) + if record: + record.status = "cancelled" + _terminate_whatsapp_pairing(record.proc) + return {"ok": True} + + _TELEGRAM_ONBOARDING_DEFAULT_URL = "https://setup.hermes-agent.nousresearch.com" _TELEGRAM_ONBOARDING_USER_AGENT = f"HermesDashboard/{__version__}" _TELEGRAM_USER_ID_RE = re.compile(r"^\d+$") diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index 82f08199631e..e79b2d8fe3df 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -490,19 +490,19 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): # QR codes to its log file and never reaches status:connected, # so every gateway restart paid the 30s timeout + queued WhatsApp # for indefinite retries. Mark non-retryable so the user gets a - # clear "run hermes whatsapp" message instead of the watcher + # clear pairing message instead of the watcher # silently hammering an unconfigured platform. creds_path = self._session_path / "creds.json" if not creds_path.exists(): logger.warning( "[%s] WhatsApp is enabled but not paired (no creds.json at %s). " - "Run `hermes whatsapp` to pair, or remove WHATSAPP_ENABLED from " - "your .env to disable.", + "Pair from the dashboard or run `hermes whatsapp`; remove " + "WHATSAPP_ENABLED from your .env to disable.", self.name, creds_path, ) self._set_fatal_error( "whatsapp_not_paired", - "WhatsApp enabled but not paired — run `hermes whatsapp` to pair.", + "WhatsApp enabled but not paired — pair from the dashboard or run `hermes whatsapp`.", retryable=False, ) return False diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 658de75e6c30..4b5733d16f84 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -100,7 +100,9 @@ try { .slice(0, 16); } catch {} const PAIR_ONLY = args.includes('--pair-only'); +const PAIR_JSON = args.includes('--pair-json'); const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat" +const WHATSAPP_DM_POLICY = String(process.env.WHATSAPP_DM_POLICY || 'open').trim().toLowerCase(); const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || ''); const DEFAULT_REPLY_PREFIX = '⚕ *Hermes Agent*\n────────────\n'; const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined @@ -197,6 +199,23 @@ function normalizeWhatsAppId(value) { return String(value).replace(':', '@'); } +function redactWhatsAppId(value) { + const raw = String(value || '').trim(); + if (!raw) return ''; + const [userPart, domainPart = ''] = raw.split('@', 2); + const bare = userPart.split(':', 1)[0]; + const digits = bare.replace(/\D/g, ''); + const suffix = digits ? digits.slice(-4) : bare.slice(-4); + return `${suffix ? `…${suffix}` : '…'}${domainPart ? `@${domainPart}` : ''}`; +} + +function emitDebugEvent(payload) { + if (!WHATSAPP_DEBUG) return; + try { + console.log(JSON.stringify({ event: 'debug', ...payload })); + } catch {} +} + function getMessageContent(msg) { const content = msg?.message || {}; if (content.ephemeralMessage?.message) return content.ephemeralMessage.message; @@ -360,6 +379,13 @@ function rememberSentId(id) { let sock = null; let connectionState = 'disconnected'; +function emitPairEvent(event) { + if (!PAIR_JSON) return; + try { + console.log(JSON.stringify({ ts: Date.now(), ...event })); + } catch {} +} + async function startSocket() { const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR); const { version } = await fetchLatestBaileysVersion(); @@ -387,9 +413,13 @@ async function startSocket() { const { connection, lastDisconnect, qr } = update; if (qr) { - console.log('\n📱 Scan this QR code with WhatsApp on your phone:\n'); - qrcode.generate(qr, { small: true }); - console.log('\nWaiting for scan...\n'); + if (PAIR_JSON) { + emitPairEvent({ event: 'qr', qr }); + } else { + console.log('\n📱 Scan this QR code with WhatsApp on your phone:\n'); + qrcode.generate(qr, { small: true }); + console.log('\nWaiting for scan...\n'); + } } if (connection === 'close') { @@ -397,22 +427,39 @@ async function startSocket() { connectionState = 'disconnected'; if (reason === DisconnectReason.loggedOut) { - console.log('❌ Logged out. Delete session and restart to re-authenticate.'); + emitPairEvent({ event: 'error', error: 'logged_out', reason }); + if (!PAIR_JSON) { + console.log('❌ Logged out. Delete session and restart to re-authenticate.'); + } process.exit(1); } else { // 515 = restart requested (common after pairing). Always reconnect. - if (reason === 515) { - console.log('↻ WhatsApp requested restart (code 515). Reconnecting...'); - } else { - console.log(`⚠️ Connection closed (reason: ${reason}). Reconnecting in 3s...`); + emitPairEvent({ event: 'disconnected', reason }); + if (!PAIR_JSON) { + if (reason === 515) { + console.log('↻ WhatsApp requested restart (code 515). Reconnecting...'); + } else { + console.log(`⚠️ Connection closed (reason: ${reason}). Reconnecting in 3s...`); + } } setTimeout(startSocket, reason === 515 ? 1000 : 3000); } } else if (connection === 'open') { connectionState = 'connected'; - console.log('✅ WhatsApp connected!'); + const connectedUser = sock?.user + ? { + id: sock.user.id || null, + name: sock.user.name || sock.user.verifiedName || null, + } + : null; + emitPairEvent({ event: 'connected', user: connectedUser }); + if (!PAIR_JSON) { + console.log('✅ WhatsApp connected!'); + } if (PAIR_ONLY) { - console.log('✅ Pairing complete. Credentials saved.'); + if (!PAIR_JSON) { + console.log('✅ Pairing complete. Credentials saved.'); + } // Give Baileys a moment to flush creds, then exit cleanly setTimeout(() => process.exit(0), 2000); } @@ -484,24 +531,29 @@ async function startSocket() { if (!msg.message) continue; const chatId = msg.key.remoteJid; - if (WHATSAPP_DEBUG) { - try { - console.log(JSON.stringify({ - event: 'upsert', type, - fromMe: !!msg.key.fromMe, chatId, - senderId: msg.key.participant || chatId, - messageKeys: Object.keys(msg.message || {}), - })); - } catch {} - } const senderId = msg.key.participant || chatId; const isGroup = chatId.endsWith('@g.us'); const senderNumber = senderId.replace(/@.*/, ''); + emitDebugEvent({ + stage: 'upsert', + type, + fromMe: !!msg.key.fromMe, + chatId: redactWhatsAppId(chatId), + senderId: redactWhatsAppId(senderId), + messageKeys: Object.keys(msg.message || {}), + }); // Handle fromMe messages based on mode let fromOwner = false; if (msg.key.fromMe) { - if (isGroup || chatId.includes('status')) continue; + if (isGroup || chatId.includes('status')) { + emitDebugEvent({ + stage: 'ignored', + reason: isGroup ? 'from_me_group' : 'from_me_status', + chatId: redactWhatsAppId(chatId), + }); + continue; + } if (WHATSAPP_MODE === 'bot') { // Bot mode: separate bot number. fromMe inbound is either @@ -546,7 +598,22 @@ async function startSocket() { const myLid = (sock.user?.lid || '').replace(/:.*@/, '@').replace(/@.*/, ''); const chatNumber = chatId.replace(/@.*/, ''); const isSelfChat = (myNumber && chatNumber === myNumber) || (myLid && chatNumber === myLid); - if (!isSelfChat) continue; + emitDebugEvent({ + stage: 'self_chat_check', + matched: !!isSelfChat, + chatId: redactWhatsAppId(chatId), + accountId: redactWhatsAppId(sock.user?.id), + accountLid: redactWhatsAppId(sock.user?.lid), + }); + if (!isSelfChat) { + emitDebugEvent({ + stage: 'ignored', + reason: 'self_chat_mismatch', + chatId: redactWhatsAppId(chatId), + senderId: redactWhatsAppId(senderId), + }); + continue; + } } } @@ -567,7 +634,7 @@ async function startSocket() { } catch {} continue; } - if (!matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) { + if (WHATSAPP_DM_POLICY !== 'pairing' && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) { try { console.log(JSON.stringify({ event: 'ignored', @@ -659,25 +726,39 @@ async function startSocket() { // Ignore Hermes' own reply messages in self-chat mode to avoid loops. if (msg.key.fromMe && ((REPLY_PREFIX && event.body.startsWith(REPLY_PREFIX)) || recentlySentIds.has(msg.key.id))) { if (WHATSAPP_DEBUG) { - try { console.log(JSON.stringify({ event: 'ignored', reason: 'agent_echo', chatId, messageId: msg.key.id })); } catch {} + emitDebugEvent({ + stage: 'ignored', + reason: 'agent_echo', + chatId: redactWhatsAppId(chatId), + messageId: msg.key.id, + }); } continue; } // Skip empty messages if (!event.body && !event.hasMedia) { - if (WHATSAPP_DEBUG) { - try { - console.log(JSON.stringify({ event: 'ignored', reason: 'empty', chatId, messageKeys: Object.keys(msg.message || {}) })); - } catch (err) { - console.error('Failed to log empty message event:', err); - } - } + emitDebugEvent({ + stage: 'ignored', + reason: 'empty', + chatId: redactWhatsAppId(chatId), + messageKeys: Object.keys(msg.message || {}), + }); continue; } messageStore.remember(msg); messageQueue.push(event); + emitDebugEvent({ + stage: 'queued', + chatId: redactWhatsAppId(chatId), + senderId: redactWhatsAppId(senderId), + fromOwner: !!fromOwner, + bodyLength: event.body.length, + hasMedia: event.hasMedia, + mediaType: event.mediaType, + queueLength: messageQueue.length, + }); if (messageQueue.length > MAX_QUEUE_SIZE) { messageQueue.shift(); } @@ -999,10 +1080,20 @@ app.get('/health', (req, res) => { // Start if (PAIR_ONLY) { // Pair-only mode: just connect, show QR, save creds, exit. No HTTP server. - console.log('📱 WhatsApp pairing mode'); - console.log(`📁 Session: ${SESSION_DIR}`); - console.log(); - startSocket(); + if (PAIR_JSON) { + emitPairEvent({ event: 'started', session: SESSION_DIR }); + } else { + console.log('📱 WhatsApp pairing mode'); + console.log(`📁 Session: ${SESSION_DIR}`); + console.log(); + } + startSocket().catch((err) => { + emitPairEvent({ event: 'error', error: err?.message || String(err) }); + if (!PAIR_JSON) { + console.error(err); + } + process.exit(1); + }); } else { app.listen(PORT, '127.0.0.1', () => { console.log(`🌉 WhatsApp bridge listening on port ${PORT} (mode: ${WHATSAPP_MODE})`); @@ -1011,6 +1102,8 @@ if (PAIR_ONLY) { console.log(`🔒 Allowed users: ${Array.from(ALLOWED_USERS).join(', ')}`); } else if (WHATSAPP_MODE === 'self-chat') { console.log(`🔒 Self-chat mode — only your own messages to yourself are processed.`); + } else if (WHATSAPP_MODE === 'bot' && WHATSAPP_DM_POLICY === 'pairing') { + console.log(`🤝 WHATSAPP_DM_POLICY=pairing — unknown DMs are forwarded for gateway pairing.`); } else { console.log(`🔒 No WHATSAPP_ALLOWED_USERS set — incoming messages are rejected.`); console.log(` Set WHATSAPP_ALLOWED_USERS= to authorize specific users,`); diff --git a/tests/hermes_cli/test_whatsapp_onboarding.py b/tests/hermes_cli/test_whatsapp_onboarding.py new file mode 100644 index 000000000000..e07abfd9904d --- /dev/null +++ b/tests/hermes_cli/test_whatsapp_onboarding.py @@ -0,0 +1,323 @@ +import asyncio +import time + + +class _FakeProc: + def __init__(self, lines=None, returncode=0): + self.stdout = iter(lines or []) + self._returncode = returncode + self.terminated = False + self.killed = False + self.pid = 12345 + + def poll(self): + return None if not self.terminated and not self.killed else self._returncode + + def wait(self, timeout=None): + return self._returncode + + def terminate(self): + self.terminated = True + + def kill(self): + self.killed = True + + +def test_whatsapp_pairing_watcher_records_qr_and_connected(): + from hermes_cli import web_server as ws + + proc = _FakeProc([ + '{"event":"started","session":"/tmp/session"}\n', + '{"event":"qr","qr":"qr-payload"}\n', + '{"event":"connected","user":{"id":"15551234567:1@s.whatsapp.net","name":"Hermes Bot"}}\n', + ]) + record = ws._WhatsAppOnboardingSession( + proc=proc, + mode="bot", + allowed_users="", + session_path="/tmp/session", + expires_at="2099-01-01T00:00:00Z", + expires_at_ts=time.time() + 600, + ) + ws._whatsapp_onboarding_sessions.clear() + ws._whatsapp_onboarding_sessions["pairing"] = record + + ws._watch_whatsapp_pairing("pairing", proc) + + assert record.status == "connected" + assert record.qr_payload == "qr-payload" + assert record.account_id == "15551234567:1@s.whatsapp.net" + assert record.account_name == "Hermes Bot" + assert record.account_phone == "15551234567" + assert record.error is None + ws._whatsapp_onboarding_sessions.clear() + + +def test_whatsapp_pairing_payload_includes_linked_account(): + from hermes_cli import web_server as ws + + record = ws._WhatsAppOnboardingSession( + proc=None, + mode="bot", + allowed_users="", + session_path="/tmp/session", + expires_at="2099-01-01T00:00:00Z", + expires_at_ts=time.time() + 600, + status="connected", + account_id="15551234567@s.whatsapp.net", + account_name="Hermes Bot", + account_phone="15551234567", + ) + + payload = ws._whatsapp_onboarding_payload("pairing", record) + + assert payload["account_id"] == "15551234567@s.whatsapp.net" + assert payload["account_name"] == "Hermes Bot" + assert payload["account_phone"] == "15551234567" + + +def test_messaging_payload_includes_safe_whatsapp_setup(monkeypatch): + from hermes_cli import web_server as ws + + entry = { + "id": "whatsapp", + "name": "WhatsApp", + "description": "WhatsApp bridge", + "docs_url": "", + "env_vars": ("WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS", "WHATSAPP_ENABLED"), + "required_env": (), + } + monkeypatch.setattr(ws, "get_running_pid", lambda: None) + monkeypatch.setattr(ws, "get_runtime_status_running_pid", lambda runtime: None) + monkeypatch.setattr( + ws, + "load_config", + lambda: { + "platforms": { + "whatsapp": { + "enabled": True, + "home_channel": { + "platform": "whatsapp", + "chat_id": "280912570925281@lid", + "name": "Home", + }, + } + } + }, + ) + + payload = ws._messaging_platform_payload( + entry, + { + "WHATSAPP_MODE": "self-chat", + "WHATSAPP_ALLOWED_USERS": "61405484224", + "WHATSAPP_ENABLED": "true", + }, + runtime=None, + scoped=True, + ) + + assert payload["whatsapp_setup"] == { + "mode": "self-chat", + "allowed_users_set": True, + "home_channel_set": True, + } + assert "61405484224" not in str(payload["whatsapp_setup"]) + + +def test_apply_whatsapp_onboarding_saves_pairing_policy(monkeypatch): + from hermes_cli import web_server as ws + + saved = {} + removed = [] + enabled = [] + + monkeypatch.setattr(ws, "save_env_value", lambda key, value: saved.setdefault(key, value)) + monkeypatch.setattr(ws, "remove_env_value", lambda key: removed.append(key)) + monkeypatch.setattr(ws, "_write_platform_enabled", lambda platform, value: enabled.append((platform, value))) + monkeypatch.setattr( + ws, + "_restart_gateway_after_whatsapp_onboarding", + lambda profile=None: {"restart_started": True, "restart_pid": 12345}, + ) + + record = ws._WhatsAppOnboardingSession( + proc=None, + mode="bot", + allowed_users="", + session_path="/tmp/session", + expires_at="2099-01-01T00:00:00Z", + expires_at_ts=time.time() + 600, + status="connected", + ) + ws._whatsapp_onboarding_sessions.clear() + ws._whatsapp_onboarding_sessions["pairing"] = record + + result = asyncio.run( + ws.apply_whatsapp_onboarding( + "pairing", + ws.WhatsAppOnboardingApply(mode="bot", allowed_users=""), + ) + ) + + assert result["ok"] is True + assert saved["WHATSAPP_MODE"] == "bot" + assert saved["WHATSAPP_DM_POLICY"] == "pairing" + assert saved["WHATSAPP_ENABLED"] == "true" + assert "WHATSAPP_ALLOWED_USERS" not in removed + assert enabled == [("whatsapp", True)] + assert "pairing" not in ws._whatsapp_onboarding_sessions + + +def test_apply_whatsapp_onboarding_self_chat_defaults_to_linked_account(monkeypatch): + from hermes_cli import web_server as ws + + saved = {} + removed = [] + + monkeypatch.setattr(ws, "save_env_value", lambda key, value: saved.setdefault(key, value)) + monkeypatch.setattr(ws, "remove_env_value", lambda key: removed.append(key)) + monkeypatch.setattr(ws, "_write_platform_enabled", lambda platform, value: None) + monkeypatch.setattr( + ws, + "_restart_gateway_after_whatsapp_onboarding", + lambda profile=None: {"restart_started": True, "restart_pid": 12345}, + ) + + record = ws._WhatsAppOnboardingSession( + proc=None, + mode="self-chat", + allowed_users="", + session_path="/tmp/session", + expires_at="2099-01-01T00:00:00Z", + expires_at_ts=time.time() + 600, + status="connected", + account_id="15551234567:1@s.whatsapp.net", + account_phone="15551234567", + ) + ws._whatsapp_onboarding_sessions.clear() + ws._whatsapp_onboarding_sessions["pairing"] = record + + result = asyncio.run( + ws.apply_whatsapp_onboarding( + "pairing", + ws.WhatsAppOnboardingApply(mode="self-chat", allowed_users=""), + ) + ) + + assert result["ok"] is True + assert saved["WHATSAPP_MODE"] == "self-chat" + assert saved["WHATSAPP_ALLOWED_USERS"] == "15551234567" + assert "WHATSAPP_ALLOWED_USERS" not in removed + assert "pairing" not in ws._whatsapp_onboarding_sessions + + +def test_start_whatsapp_onboarding_existing_creds_returns_linked_account(monkeypatch, tmp_path): + from hermes_cli import web_server as ws + + session_dir = tmp_path / "session" + session_dir.mkdir() + (session_dir / "creds.json").write_text( + '{"me":{"id":"15551234567:1@s.whatsapp.net","name":"Hermes Bot"}}', + encoding="utf-8", + ) + + old_proc = _FakeProc(returncode=1) + old_record = ws._WhatsAppOnboardingSession( + proc=old_proc, + mode="bot", + allowed_users="", + session_path=str(session_dir), + expires_at="2099-01-01T00:00:00Z", + expires_at_ts=time.time() + 600, + ) + ws._whatsapp_onboarding_sessions.clear() + ws._whatsapp_onboarding_sessions["old"] = old_record + monkeypatch.setattr(ws, "_whatsapp_session_path", lambda: session_dir) + monkeypatch.setattr(ws.secrets, "token_urlsafe", lambda size: "existing-creds") + + result = asyncio.run( + ws.start_whatsapp_onboarding( + ws.WhatsAppOnboardingStart(mode="self-chat", allowed_users="") + ) + ) + + assert result["pairing_id"] == "existing-creds" + assert result["status"] == "connected" + assert result["qr_payload"] is None + assert result["account_id"] == "15551234567:1@s.whatsapp.net" + assert result["account_name"] == "Hermes Bot" + assert result["account_phone"] == "15551234567" + assert old_record.status == "cancelled" + assert old_proc.terminated is True + assert ws._whatsapp_onboarding_sessions["existing-creds"].account_phone == "15551234567" + ws._whatsapp_onboarding_sessions.clear() + + +def test_start_whatsapp_onboarding_returns_before_bridge_spawn(monkeypatch, tmp_path): + from hermes_cli import web_server as ws + + captured = {} + + class FakeThread: + def __init__(self, *, target, args, daemon): + captured["target"] = target + captured["args"] = args + captured["daemon"] = daemon + + def start(self): + captured["started"] = True + + ws._whatsapp_onboarding_sessions.clear() + monkeypatch.setattr(ws, "_whatsapp_session_path", lambda: tmp_path / "session") + monkeypatch.setattr(ws.secrets, "token_urlsafe", lambda size: "pairing-start") + monkeypatch.setattr(ws.threading, "Thread", FakeThread) + + result = asyncio.run( + ws.start_whatsapp_onboarding( + ws.WhatsAppOnboardingStart(mode="bot", allowed_users="") + ) + ) + + assert result["pairing_id"] == "pairing-start" + assert result["status"] == "starting" + assert result["qr_payload"] is None + assert captured["target"] is ws._run_whatsapp_pairing + assert captured["args"] == ("pairing-start", tmp_path / "session", "bot") + assert captured["daemon"] is True + assert captured["started"] is True + assert ws._whatsapp_onboarding_sessions["pairing-start"].proc is None + ws._whatsapp_onboarding_sessions.clear() + + +def test_spawn_whatsapp_pairing_process_uses_json_mode(monkeypatch, tmp_path): + from gateway.platforms import whatsapp_common + from hermes_cli import web_server as ws + import hermes_constants + + bridge_dir = tmp_path / "bridge" + bridge_dir.mkdir() + (bridge_dir / "bridge.js").write_text("// bridge", encoding="utf-8") + session_dir = tmp_path / "session" + captured = {} + + monkeypatch.setattr(whatsapp_common, "resolve_whatsapp_bridge_dir", lambda: bridge_dir) + monkeypatch.setattr(hermes_constants, "find_node_executable", lambda command: "/usr/bin/node") + monkeypatch.setattr(hermes_constants, "with_hermes_node_path", lambda env=None: {}) + monkeypatch.setattr(ws, "_ensure_whatsapp_bridge_dependencies", lambda bridge_dir: None) + + def fake_popen(args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return _FakeProc() + + monkeypatch.setattr(ws.subprocess, "Popen", fake_popen) + + proc = ws._spawn_whatsapp_pairing_process(session_dir, "bot") + + assert isinstance(proc, _FakeProc) + assert "--pair-only" in captured["args"] + assert "--pair-json" in captured["args"] + assert str(session_dir) in captured["args"] + assert captured["kwargs"]["env"]["WHATSAPP_MODE"] == "bot" + assert captured["kwargs"]["env"]["WHATSAPP_DM_POLICY"] == "pairing" diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 8aa443828e4e..e63208129820 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -75,6 +75,7 @@ const PROFILE_SCOPED_PREFIXES = [ "/api/mcp", "/api/messaging/platforms", "/api/messaging/telegram/onboarding", + "/api/messaging/whatsapp/onboarding", "/api/model/info", "/api/model/set", "/api/model/auxiliary", @@ -887,6 +888,39 @@ export const api = { `/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}`, { method: "DELETE" }, ), + startWhatsAppOnboarding: (body: { + mode?: "bot" | "self-chat"; + allowed_users?: string; + }) => + fetchJSON( + "/api/messaging/whatsapp/onboarding/start", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + getWhatsAppOnboardingStatus: (pairingId: string) => + fetchJSON( + `/api/messaging/whatsapp/onboarding/${encodeURIComponent(pairingId)}`, + ), + applyWhatsAppOnboarding: ( + pairingId: string, + body: { mode?: "bot" | "self-chat"; allowed_users?: string; profile?: string }, + ) => + fetchJSON( + `/api/messaging/whatsapp/onboarding/${encodeURIComponent(pairingId)}/apply`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + cancelWhatsAppOnboarding: (pairingId: string) => + fetchJSON<{ ok: boolean }>( + `/api/messaging/whatsapp/onboarding/${encodeURIComponent(pairingId)}`, + { method: "DELETE" }, + ), // Gateway / update actions restartGateway: () => @@ -1466,6 +1500,11 @@ export interface MessagingPlatform { error_message: string | null; updated_at: string | null; home_channel: { platform: string; chat_id: string; name: string; thread_id?: string } | null; + whatsapp_setup?: { + mode?: string; + allowed_users_set?: boolean; + home_channel_set?: boolean; + } | null; env_vars: MessagingPlatformEnvVar[]; } @@ -1849,6 +1888,38 @@ export interface TelegramOnboardingApplyResponse { restart_error?: string; } +export interface WhatsAppOnboardingStartResponse { + pairing_id: string; + status: + | "starting" + | "installing" + | "waiting" + | "connected" + | "error" + | "expired" + | "cancelled"; + qr_payload?: string | null; + expires_at: string; + mode: "bot" | "self-chat"; + allowed_users: string; + account_id?: string | null; + account_name?: string | null; + account_phone?: string | null; + error?: string | null; +} + +export type WhatsAppOnboardingStatusResponse = WhatsAppOnboardingStartResponse; + +export interface WhatsAppOnboardingApplyResponse { + ok: boolean; + platform: "whatsapp"; + needs_restart: boolean; + restart_started?: boolean; + restart_action?: string; + restart_pid?: number | null; + restart_error?: string; +} + export interface SessionMessage { role: "user" | "assistant" | "system" | "tool"; content: string | null; diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index baee6b876661..061468e15cab 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -30,6 +30,7 @@ import type { MessagingPlatformEnvVar, MessagingPlatformUpdate, TelegramOnboardingStartResponse, + WhatsAppOnboardingStartResponse, } from "@/lib/api"; import { useModalBehavior } from "@/hooks/useModalBehavior"; import { usePageHeader } from "@/contexts/usePageHeader"; @@ -102,6 +103,15 @@ function isTerminalTelegramOnboardingError(error: unknown): boolean { return /\b410\b/.test(message) && /\b(expired|claimed|gone)\b/i.test(message); } +function isTerminalWhatsAppOnboardingError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /\b410\b/.test(message) && /\b(expired|gone)\b/i.test(message); +} + +function normalizeWhatsAppMode(mode: unknown): "bot" | "self-chat" | null { + return mode === "bot" || mode === "self-chat" ? mode : null; +} + export default function ChannelsPage() { const [platforms, setPlatforms] = useState([]); const [envPath, setEnvPath] = useState("~/.hermes/.env"); @@ -533,6 +543,15 @@ export default function ChannelsPage() { showToast={showToast} /> )} + {platform.id === "whatsapp" && ( + setRestartNeeded(true)} + platform={platform} + setRestartNeeded={setRestartNeeded} + showToast={showToast} + /> + )} ); @@ -542,6 +561,413 @@ export default function ChannelsPage() { ); } +function WhatsAppOnboardingPanel({ + onChanged, + onRestartNeeded, + platform, + setRestartNeeded, + showToast, +}: { + onChanged: () => Promise; + onRestartNeeded: () => void; + platform: MessagingPlatform; + setRestartNeeded: (needed: boolean) => void; + showToast: (message: string, type: "success" | "error") => void; +}) { + const configuredMode = useMemo( + () => normalizeWhatsAppMode(platform.whatsapp_setup?.mode), + [platform.whatsapp_setup?.mode], + ); + const [setup, setSetup] = useState( + null, + ); + const [qrDataUrl, setQrDataUrl] = useState(""); + const [phase, setPhase] = useState< + "idle" | "starting" | "waiting" | "connected" | "applying" + >("idle"); + const [mode, setMode] = useState<"bot" | "self-chat">( + configuredMode ?? "bot", + ); + const [allowedUsers, setAllowedUsers] = useState(""); + const [error, setError] = useState(""); + const [tick, setTick] = useState(0); + + useEffect(() => { + if (!setup && phase === "idle" && configuredMode) { + setMode(configuredMode); + } + }, [configuredMode, phase, setup]); + + const updateQr = useCallback(async (payload?: string | null) => { + if (!payload) return; + const dataUrl = await QRCode.toDataURL(payload, { + errorCorrectionLevel: "M", + margin: 3, + width: 240, + }); + setQrDataUrl(dataUrl); + }, []); + + useEffect(() => { + if (!setup || phase !== "waiting") return; + let cancelled = false; + let timeout: ReturnType | null = null; + + const poll = async () => { + try { + const status = await api.getWhatsAppOnboardingStatus(setup.pairing_id); + if (cancelled) return; + setSetup(status); + if (status.qr_payload && status.qr_payload !== setup.qr_payload) { + await updateQr(status.qr_payload); + } + if (cancelled) return; + if (status.status === "connected") { + setPhase("connected"); + setError(""); + return; + } + if (status.status === "error") { + setError(status.error || "WhatsApp setup failed."); + setSetup(null); + setQrDataUrl(""); + setPhase("idle"); + return; + } + setError(""); + timeout = setTimeout(poll, 1500); + } catch (pollError) { + if (cancelled) return; + const expiresAt = Date.parse(setup.expires_at); + const expired = + Number.isFinite(expiresAt) && Date.now() >= expiresAt; + if (isTerminalWhatsAppOnboardingError(pollError) || expired) { + setSetup(null); + setQrDataUrl(""); + setPhase("idle"); + setError("WhatsApp QR setup expired. Start a new QR setup to try again."); + return; + } + setError(`Still waiting for WhatsApp. Retrying after: ${pollError}`); + timeout = setTimeout(poll, 2000); + } + }; + + timeout = setTimeout(poll, 1000); + return () => { + cancelled = true; + if (timeout) clearTimeout(timeout); + }; + }, [phase, setup, updateQr]); + + useEffect(() => { + if (!setup) return; + const timer = setInterval(() => setTick((value) => value + 1), 1000); + return () => clearInterval(timer); + }, [setup]); + + const resetSetup = () => { + setSetup(null); + setQrDataUrl(""); + setPhase("idle"); + setError(""); + }; + + const start = async () => { + setPhase("starting"); + setError(""); + setQrDataUrl(""); + try { + const res = await api.startWhatsAppOnboarding({ + mode, + allowed_users: allowedUsers, + }); + setSetup(res); + if (res.qr_payload) { + await updateQr(res.qr_payload); + } + if (res.status === "error") { + setError(res.error || "WhatsApp setup failed."); + setSetup(null); + setPhase("idle"); + } else { + setPhase(res.status === "connected" ? "connected" : "waiting"); + } + } catch (startError) { + setPhase("idle"); + setError(String(startError)); + } + }; + + const cancel = async () => { + if (setup) { + try { + await api.cancelWhatsAppOnboarding(setup.pairing_id); + } catch { + /* local cleanup still wins */ + } + } + resetSetup(); + }; + + const watchRestartOutcome = async () => { + for (let i = 0; i < 20; i++) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + try { + const st = await api.getActionStatus("gateway-restart", 5); + if (st.running) continue; + if (st.exit_code !== 0 && st.exit_code !== null) { + onRestartNeeded(); + showToast( + `Gateway restart failed (exit ${st.exit_code}) — restart manually`, + "error", + ); + } + return; + } catch { + // transient fetch error; keep polling + } + } + }; + + const apply = async () => { + if (!setup) return; + setPhase("applying"); + setError(""); + try { + const result = await api.applyWhatsAppOnboarding(setup.pairing_id, { + mode, + allowed_users: allowedUsers, + }); + resetSetup(); + if (result.restart_started) { + showToast("WhatsApp saved; gateway restarting…", "success"); + setRestartNeeded(false); + setTimeout(() => void onChanged(), 4000); + void watchRestartOutcome(); + } else { + onRestartNeeded(); + const detail = result.restart_error ? `: ${result.restart_error}` : ""; + showToast(`WhatsApp saved; gateway restart failed${detail}`, "error"); + } + await onChanged(); + } catch (applyError) { + setPhase("connected"); + setError(String(applyError)); + } + }; + + const expiresIn = useMemo( + () => (setup ? formatExpiry(setup.expires_at) : ""), + // tick keeps the memo fresh without recalculating on every render branch. + // eslint-disable-next-line react-hooks/exhaustive-deps + [setup, tick], + ); + const setupStatusLabel = + setup?.status === "installing" + ? "preparing" + : setup?.status === "starting" + ? "starting" + : "waiting"; + const setupHelp = + phase === "connected" || phase === "applying" + ? "WhatsApp is linked but Hermes is not listening yet. Save and restart the gateway to finish setup." + : setup?.status === "installing" + ? "Preparing the WhatsApp bridge. The QR code will appear here when it is ready." + : setup?.status === "starting" + ? "Starting the WhatsApp pairing bridge. The QR code will appear here when it is ready." + : "Open WhatsApp on your phone, then go to Linked Devices and scan from there. This QR is not a browser URL."; + const linkedAccountLabel = setup?.account_phone + ? `+${setup.account_phone}` + : setup?.account_name || setup?.account_id || ""; + const linkedAccountDetail = + setup?.account_phone || setup?.account_id + ? "This is the WhatsApp account Hermes is now logged into." + : "Hermes is logged into the WhatsApp account that scanned the QR code."; + const linkedAccountChatUrl = setup?.account_phone + ? `https://wa.me/${setup.account_phone}` + : ""; + const messageInstruction = + mode === "self-chat" + ? "After the restart, open Message Yourself on the linked account and send Hermes a message." + : "After the restart, start a chat from another WhatsApp account with the linked account and send Hermes a message."; + const hasSavedAllowedUsers = Boolean(platform.whatsapp_setup?.allowed_users_set); + const pairingInstruction = + mode === "self-chat" && !allowedUsers.trim() + ? hasSavedAllowedUsers + ? "Hermes will keep the saved WhatsApp allowlist." + : "Self-chat mode will allow the linked account automatically when you save." + : !allowedUsers.trim() && hasSavedAllowedUsers + ? "Hermes will keep the saved WhatsApp allowlist." + : "If no allowed numbers were entered, Hermes replies with a pairing code. Approve it from the dashboard Pairing page."; + + return ( +
+
+
+ + {platform.configured && ( + + Existing WhatsApp settings are configured. + + )} +
+ +
+
+ + Mode + +
+ + +
+
+
+ + setAllowedUsers(event.target.value)} + disabled={phase === "waiting" || phase === "applying"} + placeholder="15551234567,15557654321" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + + {setup && ( +
+
+
+ {phase === "connected" || phase === "applying" ? ( + Connected + ) : ( + {setupStatusLabel} + )} + + {expiresIn} + +
+ +
{setupHelp}
+ + {phase === "waiting" && ( +
+ After saving, unknown DMs use Hermes pairing codes unless their + number is already allowed. +
+ )} + + {(phase === "connected" || phase === "applying") && ( +
+
+
+ {linkedAccountLabel + ? `Linked as ${linkedAccountLabel}` + : "WhatsApp device linked"} +
+
{linkedAccountDetail}
+
    +
  1. Save and restart the gateway.
  2. +
  3. {messageInstruction}
  4. +
  5. {pairingInstruction}
  6. +
+ {linkedAccountChatUrl && ( + + Open chat link + + + )} +
+
+ + +
+
+ )} +
+ +
+ {qrDataUrl ? ( + WhatsApp setup QR code + ) : phase === "connected" || phase === "applying" ? ( +
+ Linked +
+ {linkedAccountLabel || "Existing WhatsApp session found"} +
+
+ ) : ( +
+ +
+ Waiting for WhatsApp to provide a QR code… +
+
+ )} + {phase === "waiting" && ( + + Scan with WhatsApp Linked Devices, not the camera app. + + )} + +
+
+ )} +
+
+ ); +} + function TelegramOnboardingPanel({ onChanged, onRestartNeeded, From f9eca7e15f1c2bfe5194aae5aa489af53c0a1a23 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:14:48 -0700 Subject: [PATCH 189/610] chore: release v0.18.1 (2026.7.7) (#60595) --- acp_registry/agent.json | 4 ++-- hermes_cli/__init__.py | 4 ++-- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/acp_registry/agent.json b/acp_registry/agent.json index dc1e05bb27b7..f80b7eaf4452 100644 --- a/acp_registry/agent.json +++ b/acp_registry/agent.json @@ -1,7 +1,7 @@ { "id": "hermes-agent", "name": "Hermes Agent", - "version": "0.18.0", + "version": "0.18.1", "description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.", "repository": "https://github.com/NousResearch/hermes-agent", "website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp", @@ -9,7 +9,7 @@ "license": "MIT", "distribution": { "uvx": { - "package": "hermes-agent[acp]==0.18.0", + "package": "hermes-agent[acp]==0.18.1", "args": ["hermes-acp"] } } diff --git a/hermes_cli/__init__.py b/hermes_cli/__init__.py index 30daf8178595..49326ba53c64 100644 --- a/hermes_cli/__init__.py +++ b/hermes_cli/__init__.py @@ -14,8 +14,8 @@ Provides subcommands for: import os import sys -__version__ = "0.18.0" -__release_date__ = "2026.7.1" +__version__ = "0.18.1" +__release_date__ = "2026.7.7" def _ensure_utf8(): diff --git a/pyproject.toml b/pyproject.toml index 963210075e0a..b52c289ac329 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-agent" -version = "0.18.0" +version = "0.18.1" description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere" readme = "README.md" # Upper bound is load-bearing, not cosmetic. uv resolves the project's diff --git a/uv.lock b/uv.lock index b51daeca10d3..433ecd8d9575 100644 --- a/uv.lock +++ b/uv.lock @@ -1516,7 +1516,7 @@ wheels = [ [[package]] name = "hermes-agent" -version = "0.18.0" +version = "0.18.1" source = { editable = "." } dependencies = [ { name = "certifi" }, From c30c9753b6efc08e154d66b6501a444739df3859 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:49:26 -0700 Subject: [PATCH 190/610] fix(whatsapp): unpin Baileys from git commit, use published 7.0.0-rc13 (#60643) The April 2026 pin to WhiskeySockets/Baileys#01047deb existed only to pick up the abprops bad-request fix (Baileys PR #2473) before it was released. That fix shipped in v7.0.0-rc11 (May 2026); our pinned commit is now 48 commits behind rc13. The git pin forced npm to clone the repo and compile Baileys from TypeScript source on every fresh install (~3 min), which blew past the dashboard pairing flow's timeout. Registry install takes ~3s. Validation: all 9 bridge.js imports present in rc13, bridge.native.test.mjs passes (13/13), live bridge boot renders pairing QR against real WA servers. --- scripts/whatsapp-bridge/package-lock.json | 554 ++++++++++++---------- scripts/whatsapp-bridge/package.json | 2 +- 2 files changed, 296 insertions(+), 260 deletions(-) diff --git a/scripts/whatsapp-bridge/package-lock.json b/scripts/whatsapp-bridge/package-lock.json index b662982cf5a3..b3043d2889ba 100644 --- a/scripts/whatsapp-bridge/package-lock.json +++ b/scripts/whatsapp-bridge/package-lock.json @@ -8,7 +8,7 @@ "name": "hermes-whatsapp-bridge", "version": "1.0.0", "dependencies": { - "@whiskeysockets/baileys": "WhiskeySockets/Baileys#01047debd81beb20da7b7779b08edcb06aa03770", + "@whiskeysockets/baileys": "7.0.0-rc13", "express": "^4.21.0", "pino": "^9.0.0", "qrcode-terminal": "^0.12.0" @@ -25,12 +25,12 @@ } }, "node_modules/@cacheable/memory": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz", - "integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", "license": "MIT", "dependencies": { - "@cacheable/utils": "^2.4.0", + "@cacheable/utils": "^2.5.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" @@ -51,9 +51,9 @@ } }, "node_modules/@cacheable/utils": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz", - "integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", "license": "MIT", "dependencies": { "hashery": "^1.5.1", @@ -61,9 +61,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "license": "MIT", "optional": true, "peer": true, @@ -97,9 +97,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -110,19 +110,19 @@ ], "peer": true, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -133,19 +133,39 @@ ], "peer": true, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -160,9 +180,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -177,9 +197,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -194,9 +214,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -211,9 +231,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -228,9 +248,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -245,9 +265,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -262,9 +282,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -279,9 +299,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -296,9 +316,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -313,9 +333,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -326,19 +346,19 @@ ], "peer": true, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -349,19 +369,19 @@ ], "peer": true, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -372,19 +392,19 @@ ], "peer": true, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -395,19 +415,19 @@ ], "peer": true, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -418,19 +438,19 @@ ], "peer": true, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -441,19 +461,19 @@ ], "peer": true, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -464,19 +484,19 @@ ], "peer": true, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -487,39 +507,56 @@ ], "peer": true, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "peer": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@emnapi/runtime": "^1.11.1" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -530,16 +567,16 @@ ], "peer": true, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -550,16 +587,16 @@ ], "peer": true, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -570,7 +607,7 @@ ], "peer": true, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -623,19 +660,18 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -644,12 +680,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", - "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -663,9 +693,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, "node_modules/@tokenizer/inflate": { @@ -715,32 +745,31 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": "~8.3.0" } }, "node_modules/@whiskeysockets/baileys": { - "name": "baileys", - "version": "7.0.0-rc.9", - "resolved": "git+ssh://git@github.com/WhiskeySockets/Baileys.git#01047debd81beb20da7b7779b08edcb06aa03770", - "integrity": "sha512-letWyB96JHD6NdqpAiseOfaUBi13u8AhiRcKSRqcVjc5Vw5xoPTZGvVnw8K/NvGBFAvyLJkwim9Mjvwzhx/SlA==", + "version": "7.0.0-rc13", + "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-7.0.0-rc13.tgz", + "integrity": "sha512-8JPc8gaaCRykkjW2jxLGQ7/RZGrc7awO7WU+QJocf58eSUI9jAdcuYLynzhAbyU4UWvJJsHImZ+5E/JaZj5ypA==", "hasInstallScript": true, "license": "MIT", "dependencies": { "@cacheable/node-cache": "^1.4.0", "@hapi/boom": "^9.1.3", "async-mutex": "^0.5.0", - "libsignal": "git+https://github.com/whiskeysockets/libsignal-node", + "libsignal": "^6.0.0", "lru-cache": "^11.1.0", - "music-metadata": "^11.7.0", + "music-metadata": "^11.12.3", "p-queue": "^9.0.0", "pino": "^9.6", - "protobufjs": "^7.2.4", - "whatsapp-rust-bridge": "0.5.2", + "protobufjs": "^7.5.6", + "whatsapp-rust-bridge": "0.5.4", "ws": "^8.13.0" }, "engines": { @@ -748,7 +777,7 @@ }, "peerDependencies": { "audio-decode": "^2.1.3", - "jimp": "^1.6.0", + "jimp": "^1.6.1", "link-preview-js": "^3.0.0", "sharp": "*" }, @@ -825,21 +854,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -850,16 +864,16 @@ } }, "node_modules/cacheable": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.4.tgz", - "integrity": "sha512-djgxybDbw9fL/ZWMI3+CE8ZilNxcwFkVtDc1gJ+IlOSSWkSMPQabhV/XCHTQ6pwwN6aivXPZ43omTooZiX06Ew==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", "license": "MIT", "dependencies": { - "@cacheable/memory": "^2.0.8", - "@cacheable/utils": "^2.4.0", + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", - "qified": "^0.9.0" + "qified": "^0.10.1" } }, "node_modules/call-bind-apply-helpers": { @@ -1019,9 +1033,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -1052,14 +1066,14 @@ "license": "MIT" }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -1078,7 +1092,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -1234,9 +1248,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1328,13 +1342,13 @@ } }, "node_modules/libsignal": { - "name": "@whiskeysockets/libsignal-node", - "version": "2.0.1", - "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/libsignal/-/libsignal-6.0.0.tgz", + "integrity": "sha512-d/5V3YFtDljbFMufz4ncyUYGYhJl+vzAe+c2EFFBQ6bz1h8Q3IOMEGXYMzlibU60I+e8GagMMpji18iez3P1hA==", "license": "GPL-3.0", "dependencies": { "curve25519-js": "^0.0.4", - "protobufjs": "6.8.8" + "protobufjs": "^7.5.5" } }, "node_modules/long": { @@ -1344,9 +1358,9 @@ "license": "Apache-2.0" }, "node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -1362,12 +1376,16 @@ } }, "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-2.0.0.tgz", + "integrity": "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/merge-descriptors": { @@ -1428,9 +1446,9 @@ "license": "MIT" }, "node_modules/music-metadata": { - "version": "11.12.3", - "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.3.tgz", - "integrity": "sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ==", + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.13.0.tgz", + "integrity": "sha512-uXRaov9dfjSpQufXIU7sMxVZnh+FilCQv2mXn+K5EJ/decP3dTWrgvPYa5r6MtRbieNSCE708Da4J0u1UGfQIw==", "funding": [ { "type": "github", @@ -1445,11 +1463,11 @@ "dependencies": { "@borewit/text-codec": "^0.2.2", "@tokenizer/token": "^0.3.0", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "file-type": "^21.3.1", - "media-typer": "^1.1.0", - "strtok3": "^10.3.4", + "file-type": "^21.3.4", + "media-typer": "^2.0.0", + "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0", "win-guid": "^0.2.1" @@ -1458,6 +1476,19 @@ "node": ">=18" } }, + "node_modules/music-metadata/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/music-metadata/node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1524,9 +1555,9 @@ } }, "node_modules/p-queue": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.2.0.tgz", - "integrity": "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.1.tgz", + "integrity": "sha512-POWdiIPmsUPGwb4FeQ4OBg46aqmcInSWe45CKDsGHiOBiVQM9chqfQTuqhuTzcg2Vz9faTI65at0KkVyVEiCHw==", "license": "MIT", "dependencies": { "eventemitter3": "^5.0.4", @@ -1620,24 +1651,23 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz", - "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -1657,9 +1687,9 @@ } }, "node_modules/qified": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/qified/-/qified-0.9.1.tgz", - "integrity": "sha512-n7mar4T0xQ+39dE2vGTAlbxUEpndwPANH0kDef1/MYsB8Bba9wshkybIRx74qgcvKQPEWErf9AqAdYjhzY2Ilg==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", "license": "MIT", "dependencies": { "hookified": "^2.1.1" @@ -1683,12 +1713,13 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -1772,9 +1803,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "peer": true, "bin": { @@ -1836,59 +1867,64 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -1996,9 +2032,9 @@ } }, "node_modules/thread-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", "license": "MIT", "dependencies": { "real-require": "^0.2.0" @@ -2072,9 +2108,9 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, "node_modules/unpipe": { @@ -2105,9 +2141,9 @@ } }, "node_modules/whatsapp-rust-bridge": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/whatsapp-rust-bridge/-/whatsapp-rust-bridge-0.5.2.tgz", - "integrity": "sha512-6KBRNvxg6WMIwZ/euA8qVzj16qxMBzLllfmaJIP1JGAAfSvwn6nr8JDOMXeqpXPEOl71UfOG+79JwKEoT2b1Fw==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/whatsapp-rust-bridge/-/whatsapp-rust-bridge-0.5.4.tgz", + "integrity": "sha512-yYO1qSs0Fe7tGtnxOFHomocUD6IZtoAgmA4oDFyGIRZ67D3QZk3w7swA6XXFXNQngiyrg2k7tul6IrM3eUFh7A==", "license": "MIT" }, "node_modules/win-guid": { @@ -2117,9 +2153,9 @@ "license": "MIT" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/scripts/whatsapp-bridge/package.json b/scripts/whatsapp-bridge/package.json index d1c3ac113a0a..3b664108bc6c 100644 --- a/scripts/whatsapp-bridge/package.json +++ b/scripts/whatsapp-bridge/package.json @@ -8,7 +8,7 @@ "start": "node bridge.js" }, "dependencies": { - "@whiskeysockets/baileys": "WhiskeySockets/Baileys#01047debd81beb20da7b7779b08edcb06aa03770", + "@whiskeysockets/baileys": "7.0.0-rc13", "express": "^4.21.0", "qrcode-terminal": "^0.12.0", "pino": "^9.0.0" From 9de9c25f620ff7f1ce0fd5457d596052d5159596 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:11:08 -0700 Subject: [PATCH 191/610] chore: release v0.18.2 (2026.7.7.2) (#60651) --- acp_registry/agent.json | 4 ++-- hermes_cli/__init__.py | 4 ++-- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/acp_registry/agent.json b/acp_registry/agent.json index f80b7eaf4452..09319a1d02a6 100644 --- a/acp_registry/agent.json +++ b/acp_registry/agent.json @@ -1,7 +1,7 @@ { "id": "hermes-agent", "name": "Hermes Agent", - "version": "0.18.1", + "version": "0.18.2", "description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.", "repository": "https://github.com/NousResearch/hermes-agent", "website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp", @@ -9,7 +9,7 @@ "license": "MIT", "distribution": { "uvx": { - "package": "hermes-agent[acp]==0.18.1", + "package": "hermes-agent[acp]==0.18.2", "args": ["hermes-acp"] } } diff --git a/hermes_cli/__init__.py b/hermes_cli/__init__.py index 49326ba53c64..3a6233f03249 100644 --- a/hermes_cli/__init__.py +++ b/hermes_cli/__init__.py @@ -14,8 +14,8 @@ Provides subcommands for: import os import sys -__version__ = "0.18.1" -__release_date__ = "2026.7.7" +__version__ = "0.18.2" +__release_date__ = "2026.7.7.2" def _ensure_utf8(): diff --git a/pyproject.toml b/pyproject.toml index b52c289ac329..0253b6f1ba35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-agent" -version = "0.18.1" +version = "0.18.2" description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere" readme = "README.md" # Upper bound is load-bearing, not cosmetic. uv resolves the project's diff --git a/uv.lock b/uv.lock index 433ecd8d9575..f70435a95060 100644 --- a/uv.lock +++ b/uv.lock @@ -1516,7 +1516,7 @@ wheels = [ [[package]] name = "hermes-agent" -version = "0.18.1" +version = "0.18.2" source = { editable = "." } dependencies = [ { name = "certifi" }, From 4d7f8ade3e586d83003d61be76e909f364040fba Mon Sep 17 00:00:00 2001 From: ethernet Date: Tue, 7 Jul 2026 21:13:19 -0700 Subject: [PATCH 192/610] feat(install): warn pip/Homebrew installs are unsupported (CLI, TUI, desktop) (#57225) * feat(install): warn pip/Homebrew installs are unsupported (CLI, TUI, desktop) pip and Homebrew are now Unsupported install methods per website/docs/getting-started/platform-support.md. Surface a warn-don't-block deprecation notice everywhere the install method is already shown, pointing at the platform-support docs and noting these installs will not receive further updates. NixOS (Tier 2) is untouched. - hermes_cli/config.py: shared is_unsupported_install_method() / format_unsupported_install_warning() helpers so the wording and docs link stay consistent across every surface. - hermes_cli/banner.py: generalize the existing pip-only banner warning to also cover Homebrew. - hermes_cli/main.py: hermes update and hermes update --check print the warning before proceeding (still update; warn, don't block). - tui_gateway/server.py: session.info gains install_warning. - ui-tui: SessionPanel renders install_warning alongside the existing 'N commits behind' notice. - apps/desktop: SessionRuntimeInfo/GatewayEventPayload gain install_warning; applyRuntimeInfo + the live session.info event fire a snoozable warning toast via a new reportInstallMethodWarning(), mirroring the existing backend-contract-skew toast pattern. i18n strings added for en/zh/zh-hant/ja. - Tests: updated pip banner assertions for the new wording, added a Homebrew banner test, and two tui_gateway session_info tests (install_warning present for pip, absent for git). * fix(nix): make `hermes` in developement environment actually work install modules as editable overlay with uv * feat: print install method when running --version * fix: correct detect install method when running from a subtree --- .envrc | 2 +- .../hooks/use-message-stream/gateway-event.ts | 5 + .../hooks/use-session-actions/utils.ts | 4 +- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/lib/chat-messages.ts | 1 + apps/desktop/src/store/updates.ts | 39 ++++++ apps/desktop/src/types/hermes.ts | 1 + hermes_cli/banner.py | 29 ++-- hermes_cli/config.py | 60 +++++++- hermes_cli/main.py | 27 +++- nix/devShell.nix | 9 +- nix/hermes-agent.nix | 54 ++++---- nix/python.nix | 131 +++++++++++------- .../hermes_cli/test_pip_install_detection.py | 28 +++- tests/hermes_cli/test_tui_resume_flow.py | 2 +- tests/test_tui_gateway_server.py | 19 +++ tui_gateway/server.py | 12 ++ ui-tui/src/components/branding.tsx | 6 + ui-tui/src/types.ts | 1 + 23 files changed, 340 insertions(+), 95 deletions(-) diff --git a/.envrc b/.envrc index 01232045f166..663714366d26 100644 --- a/.envrc +++ b/.envrc @@ -1,4 +1,4 @@ -watch_file pyproject.toml uv.lock +watch_file pyproject.toml uv.lock hermes watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix nix/hermes-agent.nix nix/desktop.nix diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index f36d74ad73e0..f057bca4f248 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -39,6 +39,7 @@ import { import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents' import { clearActiveSessionTodos } from '@/store/todos' import { recordToolDiff } from '@/store/tool-diffs' +import { reportInstallMethodWarning } from '@/store/updates' import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events' import type { RpcEvent } from '@/types/hermes' @@ -216,6 +217,10 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { requestDesktopOnboarding(payload.credential_warning) } + if (apply) { + reportInstallMethodWarning(payload?.install_warning) + } + void refreshHermesConfig() if (modelChanged || providerChanged) { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 254a58e1298a..d299fe51b7e4 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -19,7 +19,7 @@ import { setSessions, setYoloActive } from '@/store/session' -import { reportBackendContract } from '@/store/updates' +import { reportBackendContract, reportInstallMethodWarning } from '@/store/updates' import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes' import type { ClientSessionState } from '../../../types' @@ -270,6 +270,8 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR requestDesktopOnboarding(info.credential_warning) } + reportInstallMethodWarning(info.install_warning) + if (typeof info.model === 'string') { setCurrentModel(info.model) sessionState.model = info.model diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index cd046f8f0981..0ed3e0ff8810 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -119,6 +119,7 @@ export const en: Translations = { backendOutOfDateTitle: 'Backend out of date', backendOutOfDateMessage: 'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.', + installMethodUnsupportedTitle: 'Unsupported install method', updateHermes: 'Update Hermes', updateReadyTitle: 'Update ready', updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 22792ea8fed7..10088833cd62 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -120,6 +120,7 @@ export const ja = defineLocale({ backendOutOfDateTitle: 'バックエンドが古いです', backendOutOfDateMessage: 'Hermes バックエンドがこのデスクトップビルドより古く、正常に動作しない場合があります。更新して揃えてください。', + installMethodUnsupportedTitle: 'サポート対象外のインストール方法', updateHermes: 'Hermes を更新', updateReadyTitle: '更新の準備ができました', updateReadyMessage: count => `${count} 件の新しい変更が利用可能です。`, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index b4e41e29ec54..5c10c3d6873f 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -161,6 +161,7 @@ export interface Translations { copyDetailFailed: string backendOutOfDateTitle: string backendOutOfDateMessage: string + installMethodUnsupportedTitle: string updateHermes: string updateReadyTitle: string updateReadyMessage: (count: number) => string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index bcfed891ddfb..095bf6e286f6 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -116,6 +116,7 @@ export const zhHant = defineLocale({ copyDetailFailed: '無法複製通知詳情', backendOutOfDateTitle: '後端版本過舊', backendOutOfDateMessage: '您的 Hermes 後端早於目前的桌面版本,可能無法正常運作。請更新以保持一致。', + installMethodUnsupportedTitle: '不受支援的安裝方式', updateHermes: '更新 Hermes', updateReadyTitle: '有可用更新', updateReadyMessage: count => `有 ${count} 項新變更可用。`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 5e688a5c3205..411bfcf6ac14 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -116,6 +116,7 @@ export const zh: Translations = { copyDetailFailed: '无法复制通知详情', backendOutOfDateTitle: '后端版本过旧', backendOutOfDateMessage: '你的 Hermes 后端早于当前桌面构建,可能无法正常工作。请更新以保持一致。', + installMethodUnsupportedTitle: '不受支持的安装方式', updateHermes: '更新 Hermes', updateReadyTitle: '有可用更新', updateReadyMessage: count => `有 ${count} 项新更改可用。`, diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 47ac077cd6c4..c6829420a1de 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -51,6 +51,7 @@ export type GatewayEventPayload = { cwd?: string branch?: string credential_warning?: string + install_warning?: string personality?: string usage?: Partial // agent.terminal.output — live chunk for a read-only agent terminal tab diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index bb5a6828488c..b07347fa88b2 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -111,6 +111,24 @@ function isSkewToastSnoozed(): boolean { return Number.isFinite(until) && Date.now() < until } +const INSTALL_METHOD_TOAST_ID = 'install-method-not-supported' +// Same time-based snooze pattern as the update/skew toasts: the warning is +// re-derived from every session.info (session.create/resume/activate all +// route through applyRuntimeInfo), so without a snooze it would re-pop on +// every session switch even right after the user dismissed it. +const INSTALL_METHOD_TOAST_SNOOZE_KEY = 'hermes:install-method-toast-snooze-until' +const INSTALL_METHOD_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000 + +function snoozeInstallMethodToast(): void { + persistString(INSTALL_METHOD_TOAST_SNOOZE_KEY, String(Date.now() + INSTALL_METHOD_TOAST_COOLDOWN_MS)) +} + +function isInstallMethodToastSnoozed(): boolean { + const until = Number(storedString(INSTALL_METHOD_TOAST_SNOOZE_KEY) || 0) + + return Number.isFinite(until) && Date.now() < until +} + /** * Guard against a desktop GUI talking to a backend that predates its contract * (e.g. a bb/gui-built app pointed at a `main` checkout). Rather than failing @@ -151,6 +169,27 @@ export function reportBackendContract(contract: number | undefined): void { }) } +export function reportInstallMethodWarning(message: string | undefined): void { + if (!message) { + dismissNotification(INSTALL_METHOD_TOAST_ID) + + return + } + + if (isInstallMethodToastSnoozed()) { + return + } + + notify({ + durationMs: 0, + id: INSTALL_METHOD_TOAST_ID, + kind: 'warning', + message, + onDismiss: () => snoozeInstallMethodToast(), + title: translateNow('notifications.installMethodUnsupportedTitle') + }) +} + /** * Fire a toast when an update is available, at most once per cooldown window. * Closing the toast — dismissing it or opening the updates window from it — diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 63ac7f9de403..9133fb4dfbf6 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -399,6 +399,7 @@ export interface SessionRuntimeInfo { cwd?: string desktop_contract?: number fast?: boolean + install_warning?: string model?: string personality?: string provider?: string diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 01c64c7cdaf4..7b0beef7bb35 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -2,7 +2,6 @@ Pure display functions with no HermesCLI state dependency. """ - import json import logging import os @@ -322,8 +321,8 @@ def check_for_updates() -> Optional[int]: # both the Rich banner (build_welcome_banner) and the Ink badge # (branding.tsx, guarded on `typeof === 'number' && > 0`) show nothing. try: - from hermes_cli.config import detect_install_method - if detect_install_method() == "docker": + from hermes_cli.config import detect_install_method, get_project_root + if detect_install_method(get_project_root()) == "docker": return None except Exception: pass @@ -889,17 +888,23 @@ def build_welcome_banner(console: "Console", model: str, cwd: str, except Exception: pass # Never break the banner over an update check - # Pip-install warning — `pip install hermes-agent` is not the supported - # install path (it exists on PyPI for internal/CI reasons, not end users). - # Such installs miss the git checkout + installer-managed deps, so updates, - # self-update, and issue triage don't behave correctly. Warn, don't block. + # Unsupported install-method warning — pip/PyPI and Homebrew are no + # longer an officially supported distribution method (see + # website/docs/getting-started/platform-support.md). Such installs miss + # the git checkout + installer-managed deps, so updates, self-update, and + # issue triage don't behave correctly. Warn, don't block. NixOS is fully + # supported and never hits this. try: - from hermes_cli.config import detect_install_method - if detect_install_method() == "pip": + from hermes_cli.config import ( + detect_install_method, + format_unsupported_install_warning, + is_unsupported_install_method, + get_project_root + ) + _install_method = detect_install_method(get_project_root()) + if is_unsupported_install_method(_install_method): right_lines.append( - "[bold yellow]⚠ pip install not officially supported[/]" - "[dim yellow] — exists for reasons other than user install; " - "expect instability and an inability to support issues[/]" + f"[bold yellow]⚠ {format_unsupported_install_warning(_install_method)}[/]" ) except Exception: pass # Never break the banner over the install-method check diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 184350ae3a47..e24cc220f4fa 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -441,8 +441,20 @@ def detect_install_method(project_root: Optional[Path] = None) -> str: managed = get_managed_system() if managed: return managed.lower().replace(" ", "-") - if (root / ".git").is_dir(): + + # detect git repo installs (normal installer, development env) + git_path = root / ".git" + if git_path.is_dir(): return "git" + + # detect git repo installs from worktrees + if git_path.is_file(): + try: + content = git_path.read_text(encoding="utf-8").strip() + if content.startswith("gitdir:"): + return "git" + except OSError: + pass return "pip" @@ -528,10 +540,54 @@ def recommended_update_command() -> str: managed_cmd = get_managed_update_command() if managed_cmd: return managed_cmd - method = detect_install_method() + method = detect_install_method(get_project_root()) return recommended_update_command_for_method(method) +# ============================================================================= +# Unsupported install methods (pip, Homebrew) — deprecation notice +# ============================================================================= +# +# pip/PyPI and Homebrew are NOT an officially supported distribution method +# (see website/docs/getting-started/platform-support.md, "Unsupported" +# section). pip exists on PyPI for internal/CI reasons, not end-user installs; +# Homebrew is a legacy packaging path. Unlike NixOS/Homebrew "managed mode" +# (which hard-blocks config writes), this is a warn-don't-block deprecation +# notice surfaced everywhere the user might see install-method state: the CLI +# banner, the TUI/desktop session info panel, and ``hermes update``. NixOS +# stays fully supported (Tier 2) and must never hit this path. + +PLATFORM_SUPPORT_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/getting-started/platform-support" + +_UNSUPPORTED_INSTALL_METHODS = frozenset({"pip", "homebrew"}) + + +def is_unsupported_install_method(method: str) -> bool: + """Whether ``method`` (from ``detect_install_method()``) is deprecated.""" + return method in _UNSUPPORTED_INSTALL_METHODS + + +def unsupported_install_method_label(method: str) -> str: + """Human-readable name for an unsupported install method.""" + return "pip" if method == "pip" else "Homebrew" + + +def format_unsupported_install_warning(method: str) -> str: + """Plain-text (no markup) deprecation notice for pip/Homebrew installs. + + Shared verbatim across the CLI banner, TUI/desktop ``session.info``, and + ``hermes update`` / ``hermes update --check`` so the wording — and the + docs link — stays consistent across every surface instead of drifting + into three slightly different warnings. + """ + label = unsupported_install_method_label(method) + return ( + f"{label} installs are no longer an officially supported platform and " + f"will not receive further updates. See {PLATFORM_SUPPORT_DOCS_URL} " + "for supported install methods." + ) + + # Long-form text for ``hermes update`` / ``--check`` when running inside the # Docker image. Surfaced by ``cmd_update`` and ``_cmd_update_check`` in # hermes_cli/main.py; lives here so the wording stays consistent and we diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 7326badcda89..f21388c21812 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -229,9 +229,9 @@ def _read_openai_version_fast() -> str | None: def _print_fast_version_info() -> None: from hermes_cli import __release_date__, __version__ - project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) print(f"Hermes Agent v{__version__} ({__release_date__})") - print(f"Project: {project_root}") + print(f"Install directory: {PROJECT_ROOT}") + print(f"Python: {sys.version.split()[0]}") openai_version = _read_openai_version_fast() @@ -4329,10 +4329,12 @@ def cmd_import(args): def _print_version_info(*, check_updates: bool = True) -> None: + from hermes_cli.config import detect_install_method from hermes_cli.banner import format_banner_version_label print(format_banner_version_label()) - print(f"Project: {PROJECT_ROOT}") + print(f"Install directory: {PROJECT_ROOT}") + print(f"Install method: {detect_install_method(PROJECT_ROOT)}") # Show Python version print(f"Python: {sys.version.split()[0]}") @@ -8371,8 +8373,14 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): on a PyPI install we surface a one-line notice instead of silently dropping the flag. """ - from hermes_cli.config import detect_install_method + from hermes_cli.config import ( + detect_install_method, + format_unsupported_install_warning, + is_unsupported_install_method, + ) method = detect_install_method(PROJECT_ROOT) + if is_unsupported_install_method(method): + print(f"⚠ {format_unsupported_install_warning(method)}") if method == "docker": # Docker can't ``git fetch`` from within the container. Surface the # same long-form ``docker pull`` guidance ``hermes update`` (apply @@ -9255,10 +9263,21 @@ def cmd_update(args): from hermes_cli.config import ( detect_install_method, format_docker_update_message, + format_unsupported_install_warning, is_managed, + is_unsupported_install_method, managed_error, ) + # Deprecation notice for pip/Homebrew installs — printed before the + # managed-mode early-return below so Homebrew users (who are blocked from + # applying the update here) still see it. Warn, don't block: the update + # itself still proceeds (except Homebrew, which is managed-mode blocked + # for an unrelated reason — brew owns its own upgrade path). + _install_method_for_warning = detect_install_method(PROJECT_ROOT) + if is_unsupported_install_method(_install_method_for_warning): + print(f"⚠ {format_unsupported_install_warning(_install_method_for_warning)}") + if is_managed(): managed_error("update Hermes Agent") return diff --git a/nix/devShell.nix b/nix/devShell.nix index 28fb380a913c..b7dd91fae112 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -28,13 +28,20 @@ packages = with pkgs; [ + (pkgs.runCommand "hermes" { } '' + mkdir -p $out/bin + install -Dm755 ${../hermes} $out/bin/hermes + '') uv ] ++ self'.packages.default.passthru.devDeps; shellHook = '' - echo "Hermes Agent dev shell" ${combinedNonNpm} ${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths} + + # for the devshell to pick up the src + export HERMES_PYTHON_SRC_ROOT=$(git rev-parse --show-toplevel) + echo "Hermes Agent dev shell in $HERMES_PYTHON_SRC_ROOT" echo "Ready. Run 'hermes' to start." ''; }; diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix index 3d7dc260f5e4..4013e80c1fe7 100644 --- a/nix/hermes-agent.nix +++ b/nix/hermes-agent.nix @@ -44,7 +44,7 @@ let dependency-groups = [ "all" ] ++ extraDependencyGroups; }; - hermesVenv = mkHermesVenv extraDependencyGroups; + hermesVenv = (mkHermesVenv extraDependencyGroups).venv; hermesNpmLib = callPackage ./lib.nix { inherit npm-lockfile-fix nodejs; @@ -200,33 +200,37 @@ stdenv.mkDerivation (finalAttrs: { runHook postInstall ''; - passthru = { - inherit - hermesTui - hermesWeb - hermesNpmLib - hermesVenv - ; + passthru = + let + devPython = (mkHermesVenv (extraDependencyGroups ++ [ "dev" ])).editableVenv; + in + { + inherit + hermesTui + hermesWeb + hermesNpmLib + hermesVenv + ; - # `hermesDesktop` references `finalAttrs.finalPackage` (this whole - # derivation, after all overrides are applied) so the desktop wrapper - # can prepend its `/bin` to PATH. The desktop's resolver step 4 - # ("existing hermes on PATH") then picks up the fully wrapped - # `hermes` binary — venv with all deps, bundled skills/plugins, - # runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation - # of the agent resolution in the desktop wrapper. - hermesDesktop = callPackage ./desktop.nix { - inherit hermesNpmLib electron; - hermesAgent = finalAttrs.finalPackage; + # `hermesDesktop` references `finalAttrs.finalPackage` (this whole + # derivation, after all overrides are applied) so the desktop wrapper + # can prepend its `/bin` to PATH. The desktop's resolver step 4 + # ("existing hermes on PATH") then picks up the fully wrapped + # `hermes` binary — venv with all deps, bundled skills/plugins, + # runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation + # of the agent resolution in the desktop wrapper. + hermesDesktop = callPackage ./desktop.nix { + inherit hermesNpmLib electron; + hermesAgent = finalAttrs.finalPackage; + }; + + devShellHook = '' + export HERMES_PYTHON=${devPython}/bin/python3 + ''; + + devDeps = runtimeDeps ++ [ devPython ]; }; - devShellHook = '' - export HERMES_PYTHON=${hermesVenv}/bin/python3 - ''; - - devDeps = runtimeDeps ++ [ (mkHermesVenv (extraDependencyGroups ++ [ "dev" ])) ]; - }; - meta = with lib; { description = "AI agent with advanced tool-calling capabilities"; homepage = "https://github.com/NousResearch/hermes-agent"; diff --git a/nix/python.nix b/nix/python.nix index 16d8eaedad6d..0ba5c1717ad7 100644 --- a/nix/python.nix +++ b/nix/python.nix @@ -27,7 +27,8 @@ let dependency-groups = { }; }; - mkPrebuiltOverride = final: from: dependencies: + mkPrebuiltOverride = + final: from: dependencies: hacks.nixpkgsPrebuilt { inherit from; prev = { @@ -38,64 +39,100 @@ let # Legacy alibabacloud packages ship only sdists with setup.py/setup.cfg # and no pyproject.toml, so setuptools isn't declared as a build dep. - buildSystemOverrides = final: prev: builtins.mapAttrs - (name: _: prev.${name}.overrideAttrs (old: { - nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ]; - })) - (lib.genAttrs [ - "alibabacloud-credentials-api" - "alibabacloud-endpoint-util" - "alibabacloud-gateway-dingtalk" - "alibabacloud-gateway-spi" - "alibabacloud-tea" - ] (_: null)); + buildSystemOverrides = + final: prev: + builtins.mapAttrs + ( + name: _: + prev.${name}.overrideAttrs (old: { + nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ]; + }) + ) + ( + lib.genAttrs [ + "alibabacloud-credentials-api" + "alibabacloud-endpoint-util" + "alibabacloud-gateway-dingtalk" + "alibabacloud-gateway-spi" + "alibabacloud-tea" + ] (_: null) + ); - pythonPackageOverrides = final: _prev: - if isAarch64Darwin then { - numpy = mkPrebuiltOverride final python312.pkgs.numpy { }; + pythonPackageOverrides = + final: _prev: + if isAarch64Darwin then + { + numpy = mkPrebuiltOverride final python312.pkgs.numpy { }; - pyarrow = mkPrebuiltOverride final python312.pkgs.pyarrow { }; + pyarrow = mkPrebuiltOverride final python312.pkgs.pyarrow { }; - av = mkPrebuiltOverride final python312.pkgs.av { }; + av = mkPrebuiltOverride final python312.pkgs.av { }; - humanfriendly = mkPrebuiltOverride final python312.pkgs.humanfriendly { }; + humanfriendly = mkPrebuiltOverride final python312.pkgs.humanfriendly { }; - coloredlogs = mkPrebuiltOverride final python312.pkgs.coloredlogs { - humanfriendly = [ ]; - }; + coloredlogs = mkPrebuiltOverride final python312.pkgs.coloredlogs { + humanfriendly = [ ]; + }; - onnxruntime = mkPrebuiltOverride final python312.pkgs.onnxruntime { - coloredlogs = [ ]; - numpy = [ ]; - packaging = [ ]; - }; + onnxruntime = mkPrebuiltOverride final python312.pkgs.onnxruntime { + coloredlogs = [ ]; + numpy = [ ]; + packaging = [ ]; + }; - ctranslate2 = mkPrebuiltOverride final python312.pkgs.ctranslate2 { - numpy = [ ]; - pyyaml = [ ]; - }; + ctranslate2 = mkPrebuiltOverride final python312.pkgs.ctranslate2 { + numpy = [ ]; + pyyaml = [ ]; + }; - faster-whisper = mkPrebuiltOverride final python312.pkgs.faster-whisper { - av = [ ]; - ctranslate2 = [ ]; - huggingface-hub = [ ]; - onnxruntime = [ ]; - tokenizers = [ ]; - tqdm = [ ]; - }; - } else {}; + faster-whisper = mkPrebuiltOverride final python312.pkgs.faster-whisper { + av = [ ]; + ctranslate2 = [ ]; + huggingface-hub = [ ]; + onnxruntime = [ ]; + tokenizers = [ ]; + tqdm = [ ]; + }; + } + else + { }; pythonSet = (callPackage pyproject-nix.build.packages { python = python312; }).overrideScope - (lib.composeManyExtensions [ - pyproject-build-systems.overlays.default - overlay - buildSystemOverrides - pythonPackageOverrides - ]); + ( + lib.composeManyExtensions [ + pyproject-build-systems.overlays.default + overlay + buildSystemOverrides + pythonPackageOverrides + ] + ); + + editableOverlay = workspace.mkEditablePyprojectOverlay { + root = "$HERMES_PYTHON_SRC_ROOT"; # resolved at shellHook time + }; + + workspaceRoot = ./..; + editableSet = pythonSet.overrideScope ( + lib.composeManyExtensions [ + editableOverlay + (final: prev: { + hermes-agent = prev.hermes-agent.overrideAttrs (old: { + # point straight at the real source instead of the filtered nix store copy + src = workspaceRoot; + nativeBuildInputs = old.nativeBuildInputs ++ final.resolveBuildSystem { editables = [ ]; }; + }); + }) + ] + ); in -pythonSet.mkVirtualEnv "hermes-agent-env" { - hermes-agent = dependency-groups; +{ + venv = pythonSet.mkVirtualEnv "hermes-agent-env" { + hermes-agent = dependency-groups; + }; + editableVenv = editableSet.mkVirtualEnv "hermes-agent-editable-env" { + hermes-agent = dependency-groups; + }; } diff --git a/tests/hermes_cli/test_pip_install_detection.py b/tests/hermes_cli/test_pip_install_detection.py index 673ea568759e..c852b57052ae 100644 --- a/tests/hermes_cli/test_pip_install_detection.py +++ b/tests/hermes_cli/test_pip_install_detection.py @@ -195,7 +195,33 @@ def test_banner_warns_on_pip_install(tmp_path): out = buf.getvalue() assert "officially" in out - assert "instability" in out + assert "platform-support" in out + + +def test_banner_warns_on_homebrew_install(tmp_path): + """The welcome banner surfaces a warning when the install method is homebrew.""" + import io + from rich.console import Console + from hermes_cli import banner + + hh = tmp_path / ".hermes" + hh.mkdir() + (hh / ".install_method").write_text("homebrew\n") + + with patch("hermes_cli.config.get_hermes_home", return_value=hh), \ + patch("hermes_constants.get_hermes_home", return_value=hh): + buf = io.StringIO() + console = Console(file=buf, width=400, force_terminal=False, color_system=None) + banner.build_welcome_banner( + console, model="m", cwd="/tmp", + tools=[{"function": {"name": "terminal"}}], + enabled_toolsets=["terminal"], + ) + out = buf.getvalue() + + assert "officially" in out + assert "Homebrew" in out + assert "platform-support" in out def test_banner_no_pip_warning_on_git_install(tmp_path): diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index 34de95b826d2..7c83ff2a5029 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -408,7 +408,7 @@ def test_termux_ultrafast_version_runs_before_heavy_startup( out = capsys.readouterr().out assert "Hermes Agent v" in out - assert "Project:" in out + assert "Install directory:" in out assert "Python:" in out assert "OpenAI SDK:" in out diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 5e4dfea99d93..6f58d35d82c9 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -4727,6 +4727,25 @@ def test_session_info_includes_session_title(monkeypatch): assert info["title"] == "Dashboard title" +def test_session_info_includes_install_warning_for_pip(monkeypatch): + """pip installs surface install_warning; git installs don't (issue: pip/brew deprecation).""" + monkeypatch.setattr("hermes_cli.config.detect_install_method", lambda: "pip") + + info = server._session_info(types.SimpleNamespace(tools=[], model="", provider="")) + + assert "install_warning" in info + assert "pip" in info["install_warning"] + assert "platform-support" in info["install_warning"] + + +def test_session_info_omits_install_warning_for_git(monkeypatch): + monkeypatch.setattr("hermes_cli.config.detect_install_method", lambda: "git") + + info = server._session_info(types.SimpleNamespace(tools=[], model="", provider="")) + + assert "install_warning" not in info + + # --------------------------------------------------------------------------- # History-mutating commands must reject while session.running is True. # Without these guards, prompt.submit's post-run history write either diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 3c0e8372d684..b5fa98579466 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3238,6 +3238,18 @@ def _session_info(agent, session: dict | None = None) -> dict: "usage": _get_usage(agent), "profile_name": _current_profile_name(), } + try: + from hermes_cli.config import ( + detect_install_method, + format_unsupported_install_warning, + is_unsupported_install_method, + ) + + _install_method = detect_install_method() + if is_unsupported_install_method(_install_method): + info["install_warning"] = format_unsupported_install_warning(_install_method) + except Exception: + pass try: from hermes_cli import __version__, __release_date__ diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 136b97db90cc..3c9cdd5f2512 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -412,6 +412,12 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { )} + + {info.install_warning && ( + + ! {info.install_warning} + + )} ) diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 4f7ffa225d29..14ab98ca18dd 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -150,6 +150,7 @@ export interface McpServerStatus { export interface SessionInfo { cwd?: string fast?: boolean + install_warning?: string lazy?: boolean mcp_servers?: McpServerStatus[] model: string From 2e1982f83d2b912a0eda9ec6e224cc33b863e915 Mon Sep 17 00:00:00 2001 From: Neo Guyver Date: Tue, 7 Jul 2026 13:35:15 -0700 Subject: [PATCH 193/610] Fail closed on invalid JSON/YAML/TOML writes instead of writing then reporting write_file() previously called _atomic_write() first and only ran the JSON/YAML/TOML/Python syntax check afterward as an informational lint delta -- a parse failure never set the top-level `error` key, so a corrupt structured-data write still landed on disk (and file_tools.py's files_modified gating, which keys off `error`, silently reported it as a successful modification). Move the in-process syntax check for JSON/YAML/TOML ahead of _atomic_write() and refuse the write outright on a parse failure: no temp file, no rename, nothing touches disk, and the result carries a top-level `error` so callers correctly see it as unmodified. Deliberately scoped to _FAIL_CLOSED_INPROC_EXTS (JSON/YAML/TOML), not all of LINTERS_INPROC -- .py is excluded because this codebase's own test fixtures (TestPatchReplacePostWriteVerification et al.) write arbitrary non-Python text through *.py paths purely to exercise write-mechanics; a hard block there broke 3 previously-passing tests during development. Python keeps its pre-existing non-blocking lint-delta report. Adds tests/tools/test_write_file_syntax_gate.py: invalid JSON/YAML/YML/ TOML refused with nothing written (new file) and nothing modified (existing file); valid JSON/YAML still written byte-for-byte; a non-linted extension with garbage content is unaffected; invalid Python is confirmed NOT hard-refused (still just reported). --- tests/tools/test_write_file_syntax_gate.py | 101 +++++++++++++++++++++ tools/file_operations.py | 75 +++++++++++++-- 2 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 tests/tools/test_write_file_syntax_gate.py diff --git a/tests/tools/test_write_file_syntax_gate.py b/tests/tools/test_write_file_syntax_gate.py new file mode 100644 index 000000000000..89aa4d7ced21 --- /dev/null +++ b/tests/tools/test_write_file_syntax_gate.py @@ -0,0 +1,101 @@ +"""Tests for the fail-closed pre-write syntax gate on write_file. + +Structured formats with an in-process linter (JSON/YAML/TOML) are validated +BEFORE any bytes touch disk: a candidate write that doesn't parse is refused +outright -- nothing lands on disk -- instead of being written and merely +reported afterward via the post-write lint delta. + +These run against a REAL LocalEnvironment (actual shell commands / actual +files under tmp_path), matching the existing pattern in +tests/tools/test_file_write_safety.py::TestAtomicWrite. +""" + +import json +from pathlib import Path + +import pytest + +from tools.environments.local import LocalEnvironment +from tools.file_operations import ShellFileOperations + + +@pytest.fixture +def ops(tmp_path: Path): + env = LocalEnvironment(cwd=str(tmp_path)) + return ShellFileOperations(env, cwd=str(tmp_path)) + + +class TestFailClosedSyntaxGate: + def test_invalid_json_refused_file_not_created(self, ops, tmp_path: Path): + target = tmp_path / "config.json" + res = ops.write_file(str(target), '{"a": 1,') # truncated / invalid + assert res.error is not None + assert "json" in res.error.lower() + assert not target.exists(), "invalid JSON must NOT be written to disk" + + def test_invalid_json_refused_existing_file_not_modified(self, ops, tmp_path: Path): + target = tmp_path / "config.json" + target.write_text('{"a": 1}') + res = ops.write_file(str(target), '{"a": 1,') + assert res.error is not None + assert target.read_text() == '{"a": 1}', ( + "existing valid file must be left untouched by a refused write" + ) + + def test_invalid_yaml_refused_file_not_created(self, ops, tmp_path: Path): + target = tmp_path / "config.yaml" + res = ops.write_file(str(target), 'key: "unclosed\n') + assert res.error is not None + assert "yaml" in res.error.lower() + assert not target.exists(), "invalid YAML must NOT be written to disk" + + def test_invalid_yml_extension_also_refused(self, ops, tmp_path: Path): + target = tmp_path / "config.yml" + res = ops.write_file(str(target), 'key: "unclosed\n') + assert res.error is not None + assert not target.exists() + + def test_valid_json_written_exactly(self, ops, tmp_path: Path): + target = tmp_path / "config.json" + content = json.dumps({"a": 1, "b": [1, 2, 3]}) + res = ops.write_file(str(target), content) + assert res.error is None, res.error + assert target.read_text() == content + + def test_valid_yaml_written_exactly(self, ops, tmp_path: Path): + target = tmp_path / "config.yaml" + content = "a: 1\nb:\n - 1\n - 2\n" + res = ops.write_file(str(target), content) + assert res.error is None, res.error + assert target.read_text() == content + + def test_non_linted_extension_with_garbage_still_written(self, ops, tmp_path: Path): + """Behavior for extensions with NO in-process linter is unchanged -- + garbage content is written as-is, no refusal.""" + target = tmp_path / "notes.txt" + garbage = "{{{ not json, not yaml, not anything ]]] <<<" + res = ops.write_file(str(target), garbage) + assert res.error is None, res.error + assert target.read_text() == garbage + + def test_invalid_python_is_NOT_hard_refused(self, ops, tmp_path: Path): + """Deliberate scope decision: .py keeps the pre-existing NON-BLOCKING + lint-delta report rather than a hard refusal (see + ``_FAIL_CLOSED_INPROC_EXTS`` in tools/file_operations.py for why -- + this codebase's own test suite writes arbitrary non-Python content + through *.py paths as generic write-mechanics fixtures).""" + target = tmp_path / "broken.py" + bad_python = "def foo(:\n pass\n" + res = ops.write_file(str(target), bad_python) + assert res.error is None, res.error + assert target.read_text() == bad_python + # Still surfaced via the (non-blocking) lint report: + assert res.lint is not None + assert res.lint.get("status") == "error" + assert "SyntaxError" in res.lint.get("output", "") + + def test_invalid_toml_refused_file_not_created(self, ops, tmp_path: Path): + target = tmp_path / "config.toml" + res = ops.write_file(str(target), "[section\nk = 'v'") + assert res.error is not None + assert not target.exists() diff --git a/tools/file_operations.py b/tools/file_operations.py index 78bdd8d63ca5..a3e7161d28d6 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -677,6 +677,21 @@ LINTERS_INPROC = { '.toml': _lint_toml_inproc, } +# Subset of LINTERS_INPROC that the pre-write fail-closed gate in +# ``write_file`` (see below) refuses on, rather than merely reporting. +# Deliberately excludes ``.py``: unlike JSON/YAML/TOML (atomic structured +# data blobs where "doesn't parse" always means "corrupt"), ``.py`` is +# used throughout this codebase's own test fixtures as a generic +# stand-in extension for arbitrary non-Python text content (e.g. +# ``tests/tools/test_file_operations.py``'s +# ``TestPatchReplacePostWriteVerification`` writes "hello world" / +# "hi world" through a ``*.py`` path purely to exercise write-mechanics, +# not Python validity). Hard-refusing on invalid Python would treat that +# established, exercised pattern as an error and break it. Python source +# keeps the existing (unchanged) post-write lint-delta *report* — still +# visible to the caller, just not a write-blocking refusal. +_FAIL_CLOSED_INPROC_EXTS = frozenset({'.json', '.yaml', '.yml', '.toml'}) + # Max limits for read operations MAX_LINES = 2000 MAX_LINE_LENGTH = 2000 @@ -1316,12 +1331,21 @@ class ShellFileOperations(FileOperations): files. The content never appears in the shell command string — only the file path does. - After the write, runs a post-first / pre-lazy lint check via - ``_check_lint_delta()``. If the new content is clean, the lint - call is O(one parse). If the new content has errors, the pre-write - content is linted too and only errors newly introduced by this - write are surfaced — pre-existing problems are filtered out so - the agent isn't distracted chasing them. + Before anything touches disk, a fail-closed syntax gate runs + against the CANDIDATE content: if ``path``'s extension is in + ``_FAIL_CLOSED_INPROC_EXTS`` (JSON/YAML/TOML — structured data + formats where a parse failure always means corruption) and the + candidate content doesn't parse, the write is refused outright. + No temp file, no rename, nothing on disk changes. + + After a write that clears the gate, runs a post-first / pre-lazy + lint check via ``_check_lint_delta()``. If the new content is + clean, the lint call is O(one parse). If the new content has + errors the gate didn't already catch (i.e. errors from a linter + outside ``_FAIL_CLOSED_INPROC_EXTS``, such as Python), the + pre-write content is linted too and only errors newly introduced + by this write are surfaced — pre-existing problems are filtered + out so the agent isn't distracted chasing them. Args: path: File path to write @@ -1337,6 +1361,44 @@ class ShellFileOperations(FileOperations): if _is_write_denied(path): return WriteResult(error=f"Write denied: '{path}' is a protected system/credential file.") + # ── Fail-closed pre-write syntax gate ─────────────────────────── + # Validate the CANDIDATE content BEFORE any bytes touch disk — + # previously this only ran as a post-write lint *report* that the + # caller could ignore (or that ``files_modified`` gating wouldn't + # catch, since a lint failure never set the top-level ``error`` + # key). A structured-format write that doesn't even parse (mashed + # quotes, truncated generation, wrong indentation dialect) is a + # corrupt write, not a style nit — refuse it outright instead of + # writing first and reporting the damage afterward. + # + # Scope: only extensions in ``_FAIL_CLOSED_INPROC_EXTS`` (JSON/ + # YAML/TOML). ``.py`` deliberately keeps its pre-existing, + # non-blocking lint-delta *report* instead of a hard refusal — see + # ``_FAIL_CLOSED_INPROC_EXTS``'s docstring above for why. Extensions + # with no in-process linter at all (including ones only covered by + # a shell linter) are completely unaffected — this gate never runs + # for them, so behavior there is unchanged. + # + # Checked against the raw ``content`` argument, before the + # BOM/CRLF preservation shims below run. Those shims exist purely + # to match the on-disk file's existing conventions; linting + # post-shim would false-positive a JSONDecodeError on a + # legitimately BOM-marked JSON file purely because this method + # re-adds the marker the read layer strips — see + # ``_file_has_bom``/``_UTF8_BOM`` below. + ext = os.path.splitext(path)[1].lower() + inproc_linter = LINTERS_INPROC.get(ext) if ext in _FAIL_CLOSED_INPROC_EXTS else None + if inproc_linter is not None: + _ok, _lint_err = inproc_linter(content) + if not _ok and _lint_err != "__SKIP__": + return WriteResult( + error=( + f"Refusing to write '{path}': candidate content fails " + f"{ext} syntax validation ({_lint_err}). The file was " + "NOT created or modified. Fix the content and retry." + ) + ) + # Capture pre-write content. Two consumers want it: # # 1. The lint-delta layer (for in-process linters like ast.parse @@ -1352,7 +1414,6 @@ class ShellFileOperations(FileOperations): # the UNION of in-process lint coverage and LSP coverage. For # extensions outside both sets (binaries, opaque formats), # skipping the read keeps the hot path fast. - ext = os.path.splitext(path)[1].lower() pre_content: Optional[str] = None want_pre = ext in LINTERS_INPROC or self._lsp_handles_extension(ext) if want_pre: From 6695640c1ddbd1f25ec7a4352792668731c23f13 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:25:37 -0700 Subject: [PATCH 194/610] fix(tools): make the YAML write gate syntax-only so multi-doc/tagged YAML isn't refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit safe_load() raises ComposerError on multi-document streams (k8s manifests) and ConstructorError on application-defined tags (CloudFormation !Sub, Ansible !vault) — both valid YAML syntax. Now that the linter's verdict is a fail-closed write gate, those false positives would refuse legitimate writes outright. Switch to yaml.parse() (scanner+parser only), which still catches real syntax failures. --- tests/tools/test_write_file_syntax_gate.py | 26 ++++++++++++++++++++++ tools/file_operations.py | 15 ++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_write_file_syntax_gate.py b/tests/tools/test_write_file_syntax_gate.py index 89aa4d7ced21..e0a39ff1aaf6 100644 --- a/tests/tools/test_write_file_syntax_gate.py +++ b/tests/tools/test_write_file_syntax_gate.py @@ -99,3 +99,29 @@ class TestFailClosedSyntaxGate: res = ops.write_file(str(target), "[section\nk = 'v'") assert res.error is not None assert not target.exists() + + def test_multi_document_yaml_is_valid_and_written(self, ops, tmp_path: Path): + """Multi-document streams (k8s manifests) are valid YAML *syntax* — + the gate must not refuse them just because safe_load() would raise + ComposerError on more than one document.""" + target = tmp_path / "manifests.yaml" + content = "apiVersion: v1\nkind: Namespace\n---\napiVersion: v1\nkind: ConfigMap\n" + res = ops.write_file(str(target), content) + assert res.error is None, res.error + assert target.read_text() == content + + def test_custom_tagged_yaml_is_valid_and_written(self, ops, tmp_path: Path): + """Application-defined tags (CloudFormation !Sub/!Ref, Ansible !vault) + are valid YAML syntax; only the *consumer* defines their constructors. + The gate is syntax-only and must let them through.""" + target = tmp_path / "template.yaml" + content = ( + "Resources:\n" + " Bucket:\n" + " Type: AWS::S3::Bucket\n" + " Properties:\n" + " BucketName: !Sub '${AWS::StackName}-bucket'\n" + ) + res = ops.write_file(str(target), content) + assert res.error is None, res.error + assert target.read_text() == content diff --git a/tools/file_operations.py b/tools/file_operations.py index a3e7161d28d6..35b60b0b7417 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -614,6 +614,18 @@ def _lint_yaml_inproc(content: str) -> tuple[bool, str]: """In-process YAML syntax check. Returns (ok, error_message). Skipped gracefully if PyYAML isn't installed — YAML parsing is optional. + + Deliberately a *syntax-only* scan (``yaml.parse``), not ``safe_load``: + loading rejects perfectly valid YAML that merely isn't a single plain + document — multi-document streams (``---``-separated Kubernetes + manifests raise ``ComposerError``) and application-defined tags + (CloudFormation ``!Sub``/``!Ref``, Ansible ``!vault`` raise + ``ConstructorError``). Those are content conventions for whatever + consumes the file, not syntax errors, and this linter's verdict is + used as a fail-closed WRITE gate in ``write_file`` — a false positive + here refuses a legitimate write outright. ``yaml.parse`` still + catches real scanner/parser failures (unclosed quotes, bad + indentation, tab-mangled block maps). """ try: import yaml as _yaml @@ -621,7 +633,8 @@ def _lint_yaml_inproc(content: str) -> tuple[bool, str]: # PyYAML not available — skip silently, caller treats as no linter. return True, "__SKIP__" try: - _yaml.safe_load(content) + for _event in _yaml.parse(content): + pass return True, "" except _yaml.YAMLError as e: return False, f"YAMLError: {e}" From a208b7eeb44f0b27471c9ddec8d9c1947060cbb8 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:42:24 -0700 Subject: [PATCH 195/610] chore: add AUTHOR_MAP entry for neoguyverx (PR #60526 salvage) --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 3a85bb557665..a07a268db378 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -226,6 +226,7 @@ AUTHOR_MAP = { "victor@rocketfueldev.com": "victor-kyriazakos", "87440198+JoaoMarcos44@users.noreply.github.com": "JoaoMarcos44", "joaomarcosdias444@gmail.com": "JoaoMarcos44", + "neoguyver@icloud.com": "neoguyverx", # PR #60526 salvage (fail-closed write syntax gate; #60525) "286497132+srojk34@users.noreply.github.com": "srojk34", "srojk34@users.noreply.github.com": "srojk34", # legacy prefix-less noreply (PR #50098 salvage; #38763) "pinkiilqwq@users.noreply.github.com": "PINKIIILQWQ", # PR #45035 salvage (resume-to-tip; #38763) From 862aee49564cacbbe9e397adf40f7f87cb575f16 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Wed, 8 Jul 2026 08:24:34 +0700 Subject: [PATCH 196/610] fix(gateway): drain in-flight cron jobs before shutdown tool kill /update and other shutdown paths only waited on gateway session agents, so active cron tool work was killed immediately in final-cleanup while the scheduler could still mark the job successful (#60432). --- cron/scheduler.py | 7 +++++++ gateway/run.py | 35 ++++++++++++++++++++++++++++------- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 21baab05482f..0a9a9813180f 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -298,6 +298,13 @@ _parallel_pool_max_workers: Optional[int] = None _running_job_ids: set = set() _running_lock = threading.Lock() + +def cron_jobs_in_flight() -> int: + """Return how many cron jobs are currently executing agent/tool work.""" + with _running_lock: + return len(_running_job_ids) + + # Sequential (env-mutating) cron jobs — workdir jobs that touch # process-global runtime state — must run one at a time, but must NOT block the # ticker thread. A persistent single-thread executor preserves ordering across diff --git a/gateway/run.py b/gateway/run.py index 11eff2fd9df4..a3752d5beb01 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5548,20 +5548,30 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew return True async def _drain_active_agents(self, timeout: float) -> tuple[Dict[str, Any], bool]: + from cron.scheduler import cron_jobs_in_flight + snapshot = self._snapshot_running_agents() last_active_count = self._running_agent_count() + last_cron_count = cron_jobs_in_flight() last_status_at = 0.0 def _maybe_update_status(force: bool = False) -> None: - nonlocal last_active_count, last_status_at + nonlocal last_active_count, last_cron_count, last_status_at now = asyncio.get_running_loop().time() active_count = self._running_agent_count() - if force or active_count != last_active_count or (now - last_status_at) >= 1.0: + cron_count = cron_jobs_in_flight() + if ( + force + or active_count != last_active_count + or cron_count != last_cron_count + or (now - last_status_at) >= 1.0 + ): self._update_runtime_status("draining") last_active_count = active_count + last_cron_count = cron_count last_status_at = now - if not self._running_agents: + if not self._running_agents and last_cron_count == 0: _maybe_update_status(force=True) return snapshot, False @@ -5570,10 +5580,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew return snapshot, True deadline = asyncio.get_running_loop().time() + timeout - while self._running_agents and asyncio.get_running_loop().time() < deadline: + while ( + (self._running_agents or cron_jobs_in_flight()) + and asyncio.get_running_loop().time() < deadline + ): _maybe_update_status() await asyncio.sleep(0.1) - timed_out = bool(self._running_agents) + timed_out = bool(self._running_agents or cron_jobs_in_flight()) _maybe_update_status(force=True) return snapshot, timed_out @@ -8004,16 +8017,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception as _e: logger.debug("pre-drain mark_resume_pending failed for %s: %s", _sk, _e) + from cron.scheduler import cron_jobs_in_flight + + _cron_at_start = cron_jobs_in_flight() _drain_started_at = time.monotonic() active_agents, timed_out = await self._drain_active_agents(timeout) logger.info( "Shutdown phase: drain done at +%.2fs (drain took %.2fs, " - "timed_out=%s, active_at_start=%d, active_now=%d)", + "timed_out=%s, active_at_start=%d, active_now=%d, " + "cron_at_start=%d, cron_now=%d)", _phase_elapsed(), time.monotonic() - _drain_started_at, timed_out, len(active_agents), self._running_agent_count(), + _cron_at_start, + cron_jobs_in_flight(), ) if not timed_out: @@ -8032,9 +8051,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if timed_out: logger.warning( - "Gateway drain timed out after %.1fs with %d active agent(s); interrupting remaining work.", + "Gateway drain timed out after %.1fs with %d active agent(s) " + "and %d in-flight cron job(s); interrupting remaining work.", timeout, self._running_agent_count(), + cron_jobs_in_flight(), ) # Mark forcibly-interrupted sessions as resume_pending BEFORE # interrupting the agents. This preserves each session's From e6077af2798fd9cdefc0ff0b554cb5b11b27e769 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Wed, 8 Jul 2026 08:24:34 +0700 Subject: [PATCH 197/610] test(gateway): cover cron drain during gateway shutdown (#60432) --- tests/gateway/test_update_cron_drain.py | 86 +++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/gateway/test_update_cron_drain.py diff --git a/tests/gateway/test_update_cron_drain.py b/tests/gateway/test_update_cron_drain.py new file mode 100644 index 000000000000..ee34d72cd82e --- /dev/null +++ b/tests/gateway/test_update_cron_drain.py @@ -0,0 +1,86 @@ +"""Regression tests for #60432. + +``/update`` (and other gateway shutdown paths) must drain in-flight cron jobs +before ``process_registry.kill_all()`` runs in final-cleanup. Cron work runs on +a thread-pool worker and is tracked in ``cron.scheduler._running_job_ids``, not +in ``GatewayRunner._running_agents`` — so a zero-agent drain must still wait +for cron to finish (or time out and take the interrupt/kill path). +""" +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from tests.gateway.restart_test_helpers import make_restart_runner + + +@pytest.mark.asyncio +async def test_drain_active_agents_waits_for_in_flight_cron_jobs(): + runner, _adapter = make_restart_runner() + runner._running_agents = {} + + cron_count = [1] + + def _cron_in_flight(): + return cron_count[0] + + async def finish_cron(): + await asyncio.sleep(0.15) + cron_count[0] = 0 + + with patch("cron.scheduler.cron_jobs_in_flight", side_effect=_cron_in_flight): + task = asyncio.create_task(finish_cron()) + _snapshot, timed_out = await runner._drain_active_agents(1.0) + await task + + assert timed_out is False + assert _snapshot == {} + + +@pytest.mark.asyncio +async def test_drain_active_agents_times_out_when_cron_still_running(): + runner, _adapter = make_restart_runner() + runner._running_agents = {} + + with patch("cron.scheduler.cron_jobs_in_flight", return_value=1): + _snapshot, timed_out = await runner._drain_active_agents(0.05) + + assert timed_out is True + assert _snapshot == {} + + +@pytest.mark.asyncio +async def test_gateway_stop_waits_for_cron_before_final_tool_kill(): + """Graceful cron completion must finish before final-cleanup kill_all.""" + runner, adapter = make_restart_runner() + runner._restart_drain_timeout = 1.0 + + cron_count = [1] + call_order: list[str] = [] + + def _cron_in_flight(): + return cron_count[0] + + def _fake_kill_all(task_id=None): + call_order.append("kill_all") + return 0 + + async def finish_cron(): + await asyncio.sleep(0.12) + cron_count[0] = 0 + + with ( + patch("cron.scheduler.cron_jobs_in_flight", side_effect=_cron_in_flight), + patch("gateway.status.remove_pid_file"), + patch("gateway.status.write_runtime_status"), + patch("agent.auxiliary_client.shutdown_cached_clients"), + patch("tools.process_registry.process_registry") as registry_mock, + ): + registry_mock.kill_all.side_effect = _fake_kill_all + adapter.disconnect = AsyncMock() + + cron_task = asyncio.create_task(finish_cron()) + await runner.stop() + await cron_task + + assert call_order == ["kill_all"] From 24e9ed73c2f5dd2667329d8c0a88c5ccec42936a Mon Sep 17 00:00:00 2001 From: joaomarcos Date: Tue, 7 Jul 2026 23:05:26 -0300 Subject: [PATCH 198/610] fix(gateway,cron): make shutdown drain visible to in-flight cron work Cron jobs run through cron/scheduler.py's own ThreadPoolExecutor via a standalone AIAgent (run_job/run_one_job), entirely outside GatewayRunner._running_agents -- the dict _drain_active_agents() and every other active-work check on that class reads. A gateway shutdown (/update, /restart, and SIGUSR1 all funnel through the same stop()) could log active_at_start=0 and immediately kill tool subprocesses while a cron job's terminal command was still running, with no wait and no indication anything was interrupted. Real-world impact (from the issue): a scheduled daily briefing cron job was in flight during /update, its tool subprocess got killed by the unconditional shutdown cleanup, and the job was never marked failed -- it simply never completed or delivered, with no error surfaced anywhere. A repro with a 30-minute `sleep` cron job in flight during /update reproduced the same pattern: subprocess killed at +0.22s of drain (active_at_start=0), the job's agent thread continued in-process and produced a plausible-looking final response from the truncated tool output, and the scheduler marked the run successful. Root cause is layered, not a single line: 1. GatewayRunner._drain_active_agents() only waits on _running_agents. Cron work was invisible to it, so drain returned instantly whenever the only active work was a cron job. 2. Even with visibility, the shutdown's final tool-subprocess kill (process_registry.kill_all()) is a global, unconditional sweep with no per-job targeting -- a long-running cron job that outlives the drain timeout still gets its subprocess killed. 3. cron/scheduler.py had no way to detect that a job's tool subprocess was killed out from under it mid-run; the agent thread kept going and its eventual (often degraded but plausible-looking) response got reported as a normal successful completion. Fix, three parts: - cron/scheduler.py: expose get_running_job_ids() (thread-safe snapshot of the existing _running_job_ids set, already used to prevent double-dispatch) so the gateway can read cron's in-flight state without reaching into private module internals. - gateway/run.py: GatewayRunner._active_cron_job_count() reads that snapshot. _drain_active_agents() now waits on (_running_agents OR active cron jobs), so a cron-only workload gets the same bounded wait chat sessions already get instead of an instant active_at_start=0. Shutdown drain logging gains cron_active_at_start/cron_active_now fields alongside the existing ones (unchanged, for compat). - cron/scheduler.py: mark_running_jobs_interrupted(reason), called by gateway/run.py's _kill_tool_subprocesses() right after process_registry.kill_all(), marks every job still in _running_job_ids at that instant as failed/interrupted via the existing mark_job_run() -- and records the job IDs in _interrupted_job_ids BEFORE writing, so run_one_job()'s own eventual completion for the same run (racing in its own thread) checks that flag and skips its normal write instead of clobbering the interrupted status with a false "ok" produced from the now-truncated tool output. This does not attempt to correlate a killed PID to a specific job ID (process_registry tracks PIDs, not job IDs) -- any job still dispatched at the moment of a forced kill is treated as interrupted, matching the existing coarser precedent set by _interrupt_running_agents(), which interrupts every entry in _running_agents on a drain timeout without per-agent correlation either. Deliberately out of scope (flagged in the issue as a separate, lower-priority concern): startup-time reconciliation of cron runs that started but never reached a terminal status. Testing: - tests/cron/test_shutdown_interrupt.py (12 tests): get_running_job_ids snapshot semantics, mark_running_jobs_interrupted marking/no-op/ partial-failure behavior, and -- the core race guard -- run_one_job skipping its own last_status write (both the success path and the exception path) when the shutdown path already marked the run interrupted, with a control test proving ordinary un-interrupted completions are unaffected. - tests/gateway/test_cron_active_work_drain.py (9 tests): _active_cron_job_count reading cron state and failing closed (0) if the cron module is unavailable; _drain_active_agents waiting for an in-flight cron job the same way it waits for chat sessions, timing out if the job outruns the window, and leaving existing chat-session drain behavior unchanged; a full runner.stop() integration test (drain-timeout path) proving mark_running_jobs_interrupted actually fires with the right job ID when a tool subprocess is force-killed, plus a no-op control when nothing cron-related is in flight. - tests/gateway/test_shutdown_cache_cleanup.py: added _active_cron_job_count() to that file's hand-rolled _FakeGateway test double, which stop() now calls -- without it those 8 pre-existing tests AttributeError (caught by fail-then-pass below, not a production bug). Fail-then-pass: reverted gateway/run.py + cron/scheduler.py, all 21 new tests fail (fixture/attribute errors -- the feature doesn't exist yet); restored, all 21 pass. Regression check: ran the full plausibly-affected surface -- tests/gateway/{test_gateway_shutdown,test_restart_drain, test_restart_notification,test_restart_redelivery_dedup, test_restart_resume_pending,test_restart_service_detection, test_shutdown_cache_cleanup,test_stuck_loop,test_clean_shutdown_marker, test_external_drain_control,test_session_state_cleanup, test_update_command,test_update_streaming}.py plus tests/cron/ (944 tests) -- against a clean upstream/main checkout and against this branch. Diffed the two FAILED lists: identical, 20 pre-existing failures on both sides (Windows-locale/cp1252 file-encoding issues and Unix-permission-bit assertions that don't apply on this Windows dev box), zero new failures, zero fixed-by-accident. The 8 test_shutdown_cache_cleanup.py failures found mid-development were from the _FakeGateway gap above, fixed in the same commit and confirmed clean on the final rerun (diff against baseline: exit 0). Fixes #60432 --- cron/scheduler.py | 87 +++++++- gateway/run.py | 64 +++++- tests/cron/test_shutdown_interrupt.py | 209 +++++++++++++++++++ tests/gateway/test_cron_active_work_drain.py | 185 ++++++++++++++++ tests/gateway/test_shutdown_cache_cleanup.py | 6 + 5 files changed, 538 insertions(+), 13 deletions(-) create mode 100644 tests/cron/test_shutdown_interrupt.py create mode 100644 tests/gateway/test_cron_active_work_drain.py diff --git a/cron/scheduler.py b/cron/scheduler.py index 0a9a9813180f..1994a857fede 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -305,6 +305,87 @@ def cron_jobs_in_flight() -> int: return len(_running_job_ids) +# Job IDs the gateway shutdown path force-killed the tool subprocess of +# while still in ``_running_job_ids`` (see ``mark_running_jobs_interrupted`` +# below). ``run_one_job``'s own completion path checks this set before +# writing its own ``last_status`` so a cron agent thread that keeps running +# in-process after its tool was killed out from under it — and produces a +# plausible-looking final response from truncated output — can never +# overwrite the interrupted status with a false "ok" (#60432). +_interrupted_job_ids: set = set() + + +def get_running_job_ids() -> "frozenset[str]": + """Thread-safe snapshot of cron job IDs currently executing. + + A job ID is a member from the moment ``_submit_with_guard`` dispatches + it onto the parallel/sequential pool until ``_process_job`` returns — + i.e. for the job's *entire* run, tool calls included, not just the + ticker's dispatch instant. + + The gateway shutdown path (``gateway/run.py::GatewayRunner. + _drain_active_agents``) reads this to treat in-flight cron work as + active the same way it already treats in-flight chat sessions via + ``_running_agents`` — cron jobs run through their own thread pool here, + entirely outside that dict, so without this the drain is structurally + blind to them (#60432). + """ + with _running_lock: + return frozenset(_running_job_ids) + + +def mark_running_jobs_interrupted(reason: str) -> list: + """Best-effort: mark every currently in-flight cron job interrupted. + + Called by the gateway shutdown path immediately after it force-kills + tool subprocesses (``process_registry.kill_all()``). A job whose tool + subprocess was just killed out from under it must never be allowed to + report success — even though its agent thread is still alive in this + same process and may go on to produce a plausible-looking final + response from the now-truncated tool output. + + Records the job IDs in ``_interrupted_job_ids`` BEFORE writing + ``last_status`` so ``run_one_job``'s own eventual completion for the + same job (racing in its own thread) sees the flag and skips its normal + write instead of clobbering this one — see the check near the end of + ``run_one_job``. This does not attempt to correlate the killed + subprocess PID to a specific job ID (the process registry tracks PIDs, + not cron job IDs); any job still dispatched at the moment of a forced + kill is treated as interrupted, matching the coarser precedent already + set by ``GatewayRunner._interrupt_running_agents``, which interrupts + every entry in ``_running_agents`` on a drain timeout without + per-agent correlation either. + + Returns the list of job IDs marked, for the caller to log. + """ + with _running_lock: + job_ids = list(_running_job_ids) + _interrupted_job_ids.update(job_ids) + marked = [] + for job_id in job_ids: + try: + mark_job_run(job_id, False, reason) + marked.append(job_id) + except Exception as e: + logger.warning("Failed to mark job %s interrupted: %s", job_id, e) + return marked + + +def _consume_interrupted_flag(job_id: str) -> bool: + """Return True and clear the flag if the shutdown path already marked + ``job_id`` interrupted (see ``mark_running_jobs_interrupted``). + + Called by ``run_one_job`` right before it would otherwise write its own + ``last_status``. Consuming (discarding) rather than just checking keeps + the flag from leaking across a later, unrelated run of the same job ID + (recurring jobs reuse their ID every fire).""" + with _running_lock: + if job_id in _interrupted_job_ids: + _interrupted_job_ids.discard(job_id) + return True + return False + + # Sequential (env-mutating) cron jobs — workdir jobs that touch # process-global runtime state — must run one at a time, but must NOT block the # ticker thread. A persistent single-thread executor preserves ordering across @@ -3377,12 +3458,14 @@ def run_one_job(job: dict, *, adapters=None, loop=None, verbose: bool = False) - success = False error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)" - mark_job_run(job["id"], success, error, delivery_error=delivery_error) + if not _consume_interrupted_flag(job["id"]): + mark_job_run(job["id"], success, error, delivery_error=delivery_error) return True except Exception as e: logger.error("Error processing job %s: %s", job['id'], e) - mark_job_run(job["id"], False, str(e)) + if not _consume_interrupted_flag(job["id"]): + mark_job_run(job["id"], False, str(e)) return False diff --git a/gateway/run.py b/gateway/run.py index a3752d5beb01..527da4ff5132 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4080,6 +4080,26 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew def _running_agent_count(self) -> int: return len(self._running_agents) + def _active_cron_job_count(self) -> int: + """Count of cron jobs currently executing, from the cron scheduler's + own in-flight tracking (``cron.scheduler._running_job_ids``). + + Cron jobs run through a standalone ``AIAgent`` on the scheduler's own + thread pool (``cron/scheduler.py::run_job``), entirely outside + ``self._running_agents`` — the dict every OTHER active-work check on + this class (``_running_agent_count``, ``_drain_active_agents``) reads. + Without this, the shutdown drain is structurally blind to in-flight + cron work: it can report ``active_at_start=0`` and proceed straight + to killing tool subprocesses while a cron job's terminal command is + still running (#60432). Best-effort: returns 0 if the cron module + can't be imported (e.g. a minimal test double for this class). + """ + try: + from cron.scheduler import get_running_job_ids + return len(get_running_job_ids()) + except Exception: + return 0 + # ── scale-to-zero idle detection / dormant-quiesce (Phase 0) ────────────── # The gateway-side BEHAVIOUR that consumes the relay scale-to-zero primitives # (gateway-gateway Phase 5). Pure logic lives in gateway/scale_to_zero.py; the @@ -5548,18 +5568,16 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew return True async def _drain_active_agents(self, timeout: float) -> tuple[Dict[str, Any], bool]: - from cron.scheduler import cron_jobs_in_flight - snapshot = self._snapshot_running_agents() last_active_count = self._running_agent_count() - last_cron_count = cron_jobs_in_flight() + last_cron_count = self._active_cron_job_count() last_status_at = 0.0 def _maybe_update_status(force: bool = False) -> None: nonlocal last_active_count, last_cron_count, last_status_at now = asyncio.get_running_loop().time() active_count = self._running_agent_count() - cron_count = cron_jobs_in_flight() + cron_count = self._active_cron_job_count() if ( force or active_count != last_active_count @@ -5571,6 +5589,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew last_cron_count = cron_count last_status_at = now + # Cron jobs run on the scheduler's own thread pool, outside + # ``self._running_agents`` — fold their in-flight count into the + # same wait/timeout this method already applies to chat sessions, + # or a cron job's tool work gets killed with zero warning the + # instant it's the only active thing running (#60432). if not self._running_agents and last_cron_count == 0: _maybe_update_status(force=True) return snapshot, False @@ -5581,12 +5604,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew deadline = asyncio.get_running_loop().time() + timeout while ( - (self._running_agents or cron_jobs_in_flight()) + (self._running_agents or self._active_cron_job_count()) and asyncio.get_running_loop().time() < deadline ): _maybe_update_status() await asyncio.sleep(0.1) - timed_out = bool(self._running_agents or cron_jobs_in_flight()) + timed_out = bool(self._running_agents) or bool(self._active_cron_job_count()) _maybe_update_status(force=True) return snapshot, timed_out @@ -7957,6 +7980,27 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) except Exception as _e: logger.debug("process_registry.kill_all (%s) error: %s", phase, _e) + try: + # Any cron job still dispatched at this instant just had + # its tool subprocess killed above (kill_all() has no + # per-job-ID targeting — it's a global sweep). Its agent + # thread is still alive in this process and may go on to + # produce a plausible-looking final response from the + # now-truncated tool output; mark the run interrupted so + # the scheduler can never report that as success (#60432). + # No-op when no cron job is in flight. + from cron.scheduler import mark_running_jobs_interrupted + _interrupted = mark_running_jobs_interrupted( + f"Gateway shutdown ({phase}) killed the job's tool " + "subprocess before the run finished." + ) + if _interrupted: + logger.warning( + "Shutdown (%s): marked %d in-flight cron job(s) interrupted: %s", + phase, len(_interrupted), ", ".join(_interrupted), + ) + except Exception as _e: + logger.debug("mark_running_jobs_interrupted (%s) error: %s", phase, _e) try: from tools.async_delegation import interrupt_all as _interrupt_async _async_n = _interrupt_async(reason=f"gateway shutdown ({phase})") @@ -8017,9 +8061,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception as _e: logger.debug("pre-drain mark_resume_pending failed for %s: %s", _sk, _e) - from cron.scheduler import cron_jobs_in_flight - - _cron_at_start = cron_jobs_in_flight() + _cron_at_start = self._active_cron_job_count() _drain_started_at = time.monotonic() active_agents, timed_out = await self._drain_active_agents(timeout) logger.info( @@ -8032,7 +8074,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew len(active_agents), self._running_agent_count(), _cron_at_start, - cron_jobs_in_flight(), + self._active_cron_job_count(), ) if not timed_out: @@ -8055,7 +8097,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "and %d in-flight cron job(s); interrupting remaining work.", timeout, self._running_agent_count(), - cron_jobs_in_flight(), + self._active_cron_job_count(), ) # Mark forcibly-interrupted sessions as resume_pending BEFORE # interrupting the agents. This preserves each session's diff --git a/tests/cron/test_shutdown_interrupt.py b/tests/cron/test_shutdown_interrupt.py new file mode 100644 index 000000000000..2b5077fc9d27 --- /dev/null +++ b/tests/cron/test_shutdown_interrupt.py @@ -0,0 +1,209 @@ +"""Tests for #60432: cron jobs must not be silently invisible to gateway +shutdown, and a job whose tool subprocess got killed by shutdown must +never be reported as a successful run. + +Covers the cron/scheduler.py primitives directly: + - get_running_job_ids() -- thread-safe snapshot the gateway drain reads + - mark_running_jobs_interrupted() -- called by the gateway right after + it force-kills tool subprocesses + - the interrupted-flag race guard in run_one_job(), which must win over + the job's own thread finishing normally with a plausible-looking + result AFTER its tool was already killed out from under it +""" + +from unittest.mock import patch + +import pytest + + +@pytest.fixture(autouse=True) +def _reset_scheduler_state(): + """Every test starts from a clean slate and leaves one behind, since + these sets are module-level globals shared across the test process.""" + import cron.scheduler as sched + + sched._running_job_ids.clear() + sched._interrupted_job_ids.clear() + yield + sched._running_job_ids.clear() + sched._interrupted_job_ids.clear() + + +class TestGetRunningJobIds: + def test_empty_when_nothing_running(self): + import cron.scheduler as sched + + assert sched.get_running_job_ids() == frozenset() + + def test_reflects_in_flight_jobs(self): + import cron.scheduler as sched + + sched._running_job_ids.add("job-1") + sched._running_job_ids.add("job-2") + + result = sched.get_running_job_ids() + + assert result == frozenset({"job-1", "job-2"}) + + def test_snapshot_is_immutable_and_independent(self): + """Mutating _running_job_ids after the call must not change the + already-returned snapshot -- callers (the gateway drain loop) rely + on this to safely count in a tight polling loop.""" + import cron.scheduler as sched + + sched._running_job_ids.add("job-1") + snapshot = sched.get_running_job_ids() + sched._running_job_ids.add("job-2") + + assert snapshot == frozenset({"job-1"}) + + +class TestMarkRunningJobsInterrupted: + def test_no_op_when_nothing_running(self): + import cron.scheduler as sched + + with patch("cron.scheduler.mark_job_run") as mock_mark: + marked = sched.mark_running_jobs_interrupted("shutdown") + + assert marked == [] + mock_mark.assert_not_called() + + def test_marks_every_in_flight_job(self): + import cron.scheduler as sched + + sched._running_job_ids.update({"job-1", "job-2"}) + + with patch("cron.scheduler.mark_job_run") as mock_mark: + marked = sched.mark_running_jobs_interrupted("gateway shutdown (final-cleanup)") + + assert sorted(marked) == ["job-1", "job-2"] + assert mock_mark.call_count == 2 + called_ids = {c.args[0] for c in mock_mark.call_args_list} + assert called_ids == {"job-1", "job-2"} + for c in mock_mark.call_args_list: + # success must be False -- an interrupted run is never "ok". + assert c.args[1] is False + assert "gateway shutdown" in c.args[2] + + def test_sets_interrupted_flag_for_consumption_by_run_one_job(self): + import cron.scheduler as sched + + sched._running_job_ids.add("job-1") + + with patch("cron.scheduler.mark_job_run"): + sched.mark_running_jobs_interrupted("shutdown") + + assert "job-1" in sched._interrupted_job_ids + + def test_one_job_marking_failure_does_not_block_the_others(self): + """mark_job_run raising for one job (e.g. a jobs.json write race) + must not prevent the rest from being marked -- this runs during + shutdown, there's no retry window.""" + import cron.scheduler as sched + + sched._running_job_ids.update({"job-1", "job-2"}) + + def _side_effect(job_id, success, reason, **kwargs): + if job_id == "job-1": + raise OSError("disk full") + + with patch("cron.scheduler.mark_job_run", side_effect=_side_effect): + marked = sched.mark_running_jobs_interrupted("shutdown") + + assert marked == ["job-2"] + + +class TestConsumeInterruptedFlag: + def test_false_when_not_marked(self): + import cron.scheduler as sched + + assert sched._consume_interrupted_flag("job-1") is False + + def test_true_and_clears_when_marked(self): + import cron.scheduler as sched + + sched._interrupted_job_ids.add("job-1") + + assert sched._consume_interrupted_flag("job-1") is True + # Consumed -- a second check (e.g. a later, unrelated fire of the + # same recurring job ID) must not still read as interrupted. + assert sched._consume_interrupted_flag("job-1") is False + + +class TestRunOneJobHonoursInterruptedFlag: + """run_one_job() must not let a job's own completion overwrite a + status the shutdown path already wrote for the same run.""" + + def _make_job(self, job_id="job-1"): + return {"id": job_id, "name": "test job", "prompt": "do work"} + + def test_success_path_skipped_when_interrupted(self): + import cron.scheduler as sched + + job = self._make_job() + sched._interrupted_job_ids.add(job["id"]) + + with patch("cron.scheduler.claim_dispatch", return_value=True), \ + patch("agent.secret_scope.set_secret_scope", return_value=None), \ + patch("agent.secret_scope.build_profile_secret_scope", return_value=None), \ + patch("agent.secret_scope.reset_secret_scope"), \ + patch( + "cron.scheduler.run_job", + return_value=(True, "full output", "final response", None), + ), \ + patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \ + patch("cron.scheduler._is_cron_silence_response", return_value=False), \ + patch("cron.scheduler._deliver_result", return_value=None), \ + patch("cron.scheduler.mark_job_run") as mock_mark: + result = sched.run_one_job(job) + + assert result is True + # The would-be "success" write must NOT happen -- the shutdown + # path already wrote the authoritative interrupted status. + mock_mark.assert_not_called() + # Flag is consumed so a later, unrelated fire of the same job ID + # isn't permanently silenced. + assert job["id"] not in sched._interrupted_job_ids + + def test_success_path_writes_normally_when_not_interrupted(self): + """Control case: the guard must not swallow ordinary, un-interrupted + completions -- only ones the shutdown path explicitly flagged.""" + import cron.scheduler as sched + + job = self._make_job() + + with patch("cron.scheduler.claim_dispatch", return_value=True), \ + patch("agent.secret_scope.set_secret_scope", return_value=None), \ + patch("agent.secret_scope.build_profile_secret_scope", return_value=None), \ + patch("agent.secret_scope.reset_secret_scope"), \ + patch( + "cron.scheduler.run_job", + return_value=(True, "full output", "final response", None), + ), \ + patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \ + patch("cron.scheduler._is_cron_silence_response", return_value=False), \ + patch("cron.scheduler._deliver_result", return_value=None), \ + patch("cron.scheduler.mark_job_run") as mock_mark: + result = sched.run_one_job(job) + + assert result is True + mock_mark.assert_called_once() + assert mock_mark.call_args.args[0] == job["id"] + assert mock_mark.call_args.args[1] is True # success + + def test_exception_path_also_honours_interrupted_flag(self): + import cron.scheduler as sched + + job = self._make_job() + sched._interrupted_job_ids.add(job["id"]) + + with patch("cron.scheduler.claim_dispatch", return_value=True), \ + patch("agent.secret_scope.set_secret_scope", return_value=None), \ + patch("agent.secret_scope.build_profile_secret_scope", return_value=None), \ + patch("agent.secret_scope.reset_secret_scope"), \ + patch("cron.scheduler.run_job", side_effect=RuntimeError("boom")), \ + patch("cron.scheduler.mark_job_run") as mock_mark: + result = sched.run_one_job(job) + + assert result is False + mock_mark.assert_not_called() diff --git a/tests/gateway/test_cron_active_work_drain.py b/tests/gateway/test_cron_active_work_drain.py new file mode 100644 index 000000000000..f4bf1ca0af7d --- /dev/null +++ b/tests/gateway/test_cron_active_work_drain.py @@ -0,0 +1,185 @@ +"""Tests for #60432: the gateway shutdown drain was structurally blind to +in-flight cron work. Cron jobs run through cron/scheduler.py's own thread +pool, entirely outside ``GatewayRunner._running_agents`` -- the dict every +other active-work check on this class reads. A shutdown (``/update``, +``/restart``, SIGUSR1 -- they all funnel through the same ``stop()``) could +report ``active_at_start=0`` and immediately kill tool subprocesses while a +cron job's terminal command was still running. + +These tests cover the gateway side of the fix: + - _active_cron_job_count() reads cron.scheduler's in-flight job set + - _drain_active_agents() waits for cron work the same way it already + waits for chat sessions + - the final tool-subprocess kill marks any still-in-flight cron job + interrupted + +See tests/cron/test_shutdown_interrupt.py for the cron-side primitives +this relies on (get_running_job_ids, mark_running_jobs_interrupted). +""" + +import asyncio +from unittest.mock import MagicMock, patch + +import pytest + +from tests.gateway.restart_test_helpers import make_restart_runner + + +@pytest.fixture(autouse=True) +def _reset_cron_running_set(): + import cron.scheduler as sched + + sched._running_job_ids.clear() + sched._interrupted_job_ids.clear() + yield + sched._running_job_ids.clear() + sched._interrupted_job_ids.clear() + + +def _make_async_noop(): + async def _noop(*args, **kwargs): + return None + + return _noop + + +class TestActiveCronJobCount: + def test_zero_when_no_cron_jobs_running(self): + runner, _adapter = make_restart_runner() + assert runner._active_cron_job_count() == 0 + + def test_reflects_cron_scheduler_state(self): + import cron.scheduler as sched + + runner, _adapter = make_restart_runner() + sched._running_job_ids.add("job-1") + + assert runner._active_cron_job_count() == 1 + + def test_never_raises_if_cron_module_unavailable(self): + """Best-effort: a broken/absent import must not take shutdown + counting down with it.""" + runner, _adapter = make_restart_runner() + + with patch( + "cron.scheduler.get_running_job_ids", side_effect=ImportError("boom") + ): + assert runner._active_cron_job_count() == 0 + + +class TestDrainWaitsForCronWork: + @pytest.mark.asyncio + async def test_drain_returns_immediately_when_nothing_active(self): + runner, _adapter = make_restart_runner() + + _snapshot, timed_out = await runner._drain_active_agents(5.0) + + assert timed_out is False + + @pytest.mark.asyncio + async def test_drain_waits_for_in_flight_cron_job(self): + """Before this fix, a cron-only workload made active_at_start=0 + and the drain returned instantly -- this is the exact repro from + the issue (a `sleep 1800` cron job in flight during /update).""" + import cron.scheduler as sched + + runner, _adapter = make_restart_runner() + sched._running_job_ids.add("job-1") + + async def finish_job(): + await asyncio.sleep(0.12) + sched._running_job_ids.discard("job-1") + + task = asyncio.create_task(finish_job()) + _snapshot, timed_out = await runner._drain_active_agents(2.0) + await task + + assert timed_out is False, ( + "drain must wait for the cron job to finish, not report " + "active_at_start=0 and return instantly" + ) + + @pytest.mark.asyncio + async def test_drain_times_out_if_cron_job_outlives_the_window(self): + import cron.scheduler as sched + + runner, _adapter = make_restart_runner() + sched._running_job_ids.add("job-1") # never removed within the window + + _snapshot, timed_out = await runner._drain_active_agents(0.1) + + assert timed_out is True + + @pytest.mark.asyncio + async def test_drain_still_waits_for_chat_sessions_unchanged(self): + """Regression guard: folding cron into the check must not break + the pre-existing chat-session drain behavior.""" + runner, _adapter = make_restart_runner() + runner._running_agents = {"session-1": MagicMock()} + + async def finish_agent(): + await asyncio.sleep(0.12) + runner._running_agents.clear() + + task = asyncio.create_task(finish_agent()) + _snapshot, timed_out = await runner._drain_active_agents(2.0) + await task + + assert timed_out is False + + +class TestKillToolSubprocessesMarksCronInterrupted: + @pytest.mark.asyncio + async def test_in_flight_cron_job_marked_interrupted_on_forced_kill(self, monkeypatch): + import cron.scheduler as sched + import tools.process_registry as _pr + import tools.terminal_tool as _tt + import tools.browser_tool as _bt + + runner, adapter = make_restart_runner() + runner._restart_drain_timeout = 0.01 # force the timeout path + adapter.disconnect = _make_async_noop() + + sched._running_job_ids.add("job-1") + + monkeypatch.setattr(_pr.process_registry, "kill_all", lambda task_id=None: 1) + monkeypatch.setattr(_tt, "cleanup_all_environments", lambda: None) + monkeypatch.setattr(_bt, "cleanup_all_browsers", lambda: None) + + marked_calls = [] + real_mark = sched.mark_running_jobs_interrupted + + def _spy(reason): + result = real_mark(reason) + marked_calls.append((reason, result)) + return result + + monkeypatch.setattr(sched, "mark_running_jobs_interrupted", _spy) + + with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"), \ + patch("cron.scheduler.mark_job_run"): + await runner.stop() + + assert marked_calls, "mark_running_jobs_interrupted was never called during shutdown" + assert any(result == ["job-1"] for _reason, result in marked_calls) + + @pytest.mark.asyncio + async def test_no_cron_jobs_running_is_a_silent_no_op(self, monkeypatch): + """Graceful shutdown with nothing in flight must not spuriously + mark or log anything cron-related.""" + import tools.process_registry as _pr + import tools.terminal_tool as _tt + import tools.browser_tool as _bt + + runner, adapter = make_restart_runner() + adapter.disconnect = _make_async_noop() + + monkeypatch.setattr(_pr.process_registry, "kill_all", lambda task_id=None: 0) + monkeypatch.setattr(_tt, "cleanup_all_environments", lambda: None) + monkeypatch.setattr(_bt, "cleanup_all_browsers", lambda: None) + + with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"), \ + patch("cron.scheduler.mark_job_run") as mock_mark: + await runner.stop() + + mock_mark.assert_not_called() diff --git a/tests/gateway/test_shutdown_cache_cleanup.py b/tests/gateway/test_shutdown_cache_cleanup.py index 1b122a0d105a..e03f651c9628 100644 --- a/tests/gateway/test_shutdown_cache_cleanup.py +++ b/tests/gateway/test_shutdown_cache_cleanup.py @@ -54,6 +54,12 @@ class _FakeGateway: def _running_agent_count(self): return len(self._running_agents) + def _active_cron_job_count(self): + # stop() reads this alongside _running_agent_count when logging the + # drain snapshot (#60432) -- this fake has no cron scheduler, so + # there's never in-flight cron work to report. + return 0 + def _update_runtime_status(self, *_a, **_kw): pass From 8a573bb6e7d595efb6f2b94d7645c45a65d49469 Mon Sep 17 00:00:00 2001 From: joaomarcos Date: Tue, 7 Jul 2026 23:22:57 -0300 Subject: [PATCH 199/610] fix(cron): stop interrupted jobs from delivering their pre-kill output Follow-up to the previous commit on #60432. The status-write guard (_consume_interrupted_flag, checked right before mark_job_run) closes the false-success bookkeeping gap, but run_one_job delivers its result BEFORE that check: delivery happens right after run_job() returns, mark_job_run happens at the very end. A job whose tool subprocess was killed mid-flight can still produce a plausible-looking final_response from the truncated output, and that response would reach the user via _deliver_result before the interrupted flag was ever consulted -- correct status in jobs.json, wrong message already sent. Adds _is_interrupted(), a non-destructive peek at the same _interrupted_job_ids set (_consume_interrupted_flag stays as the consuming, authoritative check right before the status write -- this needed a peek instead since the flag has to still be visible there). Checked right after save_job_output, before the deliver_content decision: if the run looked successful but was flagged interrupted, force success=False with an explicit interruption message. This routes delivery through the existing _summarize_cron_failure_for_delivery path (the same one a real failure already uses) instead of the raw final_response, so the user gets an honest "this run was interrupted" instead of a truncated/misleading result. Testing: 4 new tests in tests/cron/test_shutdown_interrupt.py -- _is_interrupted peek semantics (false/true/does-not-clear, as opposed to the consuming _consume_interrupted_flag), and the delivery-gate test itself, which mocks run_job to return a normal-looking success with a "plausible final response" while the job is pre-marked interrupted, and asserts _deliver_result receives the failure summary ("This run was interrupted.") instead, with the summarizer's error argument confirmed to mention the interruption. Fail-then-pass: reverted cron/scheduler.py only, the 4 new tests fail (3 on the missing _is_interrupted attribute, 1 -- the delivery-gate test -- on _summarize_cron_failure_for_delivery never being called, i.e. the raw response would have gone out); restored, all 16 tests in the file pass. Regression: tests/cron/ (683 tests) + test_cron_active_work_drain.py + test_gateway_shutdown.py + test_shutdown_cache_cleanup.py -- 11 pre-existing failures (Unix file-permission-bit and path-tilde assertions that don't apply on this Windows dev box), matching the same set already established as pre-existing in the prior commit's regression check. Zero new failures. Continues #60432 --- cron/scheduler.py | 29 +++++++++++ tests/cron/test_shutdown_interrupt.py | 69 +++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/cron/scheduler.py b/cron/scheduler.py index 1994a857fede..6b96fb2e1bea 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -371,6 +371,21 @@ def mark_running_jobs_interrupted(reason: str) -> list: return marked +def _is_interrupted(job_id: str) -> bool: + """Non-destructive peek at whether the shutdown path has marked + ``job_id`` interrupted (see ``mark_running_jobs_interrupted``). + + Called by ``run_one_job`` BEFORE it decides what to deliver — a job + whose tool subprocess was killed mid-flight may still produce a + plausible-looking ``final_response`` from the truncated output, and + that must not go out to the user as if it were a normal result. + Unlike ``_consume_interrupted_flag`` below, this does not clear the + flag: the later, authoritative check (right before ``last_status`` is + written) still needs to see it.""" + with _running_lock: + return job_id in _interrupted_job_ids + + def _consume_interrupted_flag(job_id: str) -> bool: """Return True and clear the flag if the shutdown path already marked ``job_id`` interrupted (see ``mark_running_jobs_interrupted``). @@ -3420,6 +3435,20 @@ def run_one_job(job: dict, *, adapters=None, loop=None, verbose: bool = False) - if verbose: logger.info("Output saved to: %s", output_file) + # If the gateway shutdown killed this job's tool subprocess + # mid-flight (#60432), the agent may still have produced a + # plausible-looking final_response from the truncated output -- + # force the failure path so the delivered message is an honest + # "this run was interrupted" summary instead of that response. + # Peek-only: the flag stays set for the authoritative check + # right before mark_job_run below. + if success and _is_interrupted(job["id"]): + success = False + error = ( + "Interrupted by gateway shutdown before the run finished " + "(tool subprocess was killed mid-flight)." + ) + # Deliver the final response to the origin/target chat. # If the agent responded with [SILENT], skip delivery (but # output is already saved above). Failed jobs always deliver. diff --git a/tests/cron/test_shutdown_interrupt.py b/tests/cron/test_shutdown_interrupt.py index 2b5077fc9d27..a95567ff88b4 100644 --- a/tests/cron/test_shutdown_interrupt.py +++ b/tests/cron/test_shutdown_interrupt.py @@ -113,6 +113,35 @@ class TestMarkRunningJobsInterrupted: assert marked == ["job-2"] +class TestIsInterrupted: + """Peek-only check used at the delivery gate -- must NOT clear the + flag, unlike _consume_interrupted_flag.""" + + def test_false_when_not_marked(self): + import cron.scheduler as sched + + assert sched._is_interrupted("job-1") is False + + def test_true_when_marked(self): + import cron.scheduler as sched + + sched._interrupted_job_ids.add("job-1") + + assert sched._is_interrupted("job-1") is True + + def test_does_not_clear_the_flag(self): + import cron.scheduler as sched + + sched._interrupted_job_ids.add("job-1") + + sched._is_interrupted("job-1") + + # Still set -- the later, authoritative check before mark_job_run + # must still see it. + assert "job-1" in sched._interrupted_job_ids + assert sched._is_interrupted("job-1") is True + + class TestConsumeInterruptedFlag: def test_false_when_not_marked(self): import cron.scheduler as sched @@ -165,6 +194,46 @@ class TestRunOneJobHonoursInterruptedFlag: # isn't permanently silenced. assert job["id"] not in sched._interrupted_job_ids + def test_interrupted_job_delivers_failure_summary_not_raw_response(self): + """The status-write guard alone isn't enough: delivery happens + BEFORE mark_job_run in run_one_job's own flow, so a job that kept + running post-kill and produced a plausible-looking final_response + must not have that response sent to the user just because the + eventual status write gets suppressed. Interrupted jobs must route + through the same failure-summary delivery path a real failure + would.""" + import cron.scheduler as sched + + job = self._make_job() + sched._interrupted_job_ids.add(job["id"]) + + with patch("cron.scheduler.claim_dispatch", return_value=True), \ + patch("agent.secret_scope.set_secret_scope", return_value=None), \ + patch("agent.secret_scope.build_profile_secret_scope", return_value=None), \ + patch("agent.secret_scope.reset_secret_scope"), \ + patch( + "cron.scheduler.run_job", + return_value=(True, "full output", "a plausible final response", None), + ), \ + patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \ + patch( + "cron.scheduler._summarize_cron_failure_for_delivery", + return_value="This run was interrupted.", + ) as mock_summarize, \ + patch("cron.scheduler._is_cron_silence_response", return_value=False), \ + patch("cron.scheduler._deliver_result", return_value=None) as mock_deliver, \ + patch("cron.scheduler.mark_job_run"): + result = sched.run_one_job(job) + + assert result is True + mock_summarize.assert_called_once() + # The summarizer's error argument must mention the interruption, + # not be silently None / the agent's own (possibly absent) error. + assert "interrupt" in mock_summarize.call_args.args[1].lower() + delivered_content = mock_deliver.call_args.args[1] + assert delivered_content == "This run was interrupted." + assert "plausible final response" not in delivered_content + def test_success_path_writes_normally_when_not_interrupted(self): """Control case: the guard must not swallow ordinary, un-interrupted completions -- only ones the shutdown path explicitly flagged.""" From ecc6725855280b4da7181c12bf930d95c7ae9a8d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:34:03 -0700 Subject: [PATCH 200/610] fix(gateway,cron): reconcile #60612 + #60631 onto one drain surface Keep #60631's get_running_job_ids() snapshot + _active_cron_job_count() (import-guarded for minimal test doubles) as the single read path, and retarget #60612's drain tests at it. Drops the redundant cron_jobs_in_flight() helper so there is one surface, not two. --- cron/scheduler.py | 7 ------- tests/gateway/test_update_cron_drain.py | 10 +++++----- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 6b96fb2e1bea..9e645d3728f3 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -298,13 +298,6 @@ _parallel_pool_max_workers: Optional[int] = None _running_job_ids: set = set() _running_lock = threading.Lock() - -def cron_jobs_in_flight() -> int: - """Return how many cron jobs are currently executing agent/tool work.""" - with _running_lock: - return len(_running_job_ids) - - # Job IDs the gateway shutdown path force-killed the tool subprocess of # while still in ``_running_job_ids`` (see ``mark_running_jobs_interrupted`` # below). ``run_one_job``'s own completion path checks this set before diff --git a/tests/gateway/test_update_cron_drain.py b/tests/gateway/test_update_cron_drain.py index ee34d72cd82e..9fb03675e5b8 100644 --- a/tests/gateway/test_update_cron_drain.py +++ b/tests/gateway/test_update_cron_drain.py @@ -22,13 +22,13 @@ async def test_drain_active_agents_waits_for_in_flight_cron_jobs(): cron_count = [1] def _cron_in_flight(): - return cron_count[0] + return frozenset(f"job-{i}" for i in range(cron_count[0])) async def finish_cron(): await asyncio.sleep(0.15) cron_count[0] = 0 - with patch("cron.scheduler.cron_jobs_in_flight", side_effect=_cron_in_flight): + with patch("cron.scheduler.get_running_job_ids", side_effect=_cron_in_flight): task = asyncio.create_task(finish_cron()) _snapshot, timed_out = await runner._drain_active_agents(1.0) await task @@ -42,7 +42,7 @@ async def test_drain_active_agents_times_out_when_cron_still_running(): runner, _adapter = make_restart_runner() runner._running_agents = {} - with patch("cron.scheduler.cron_jobs_in_flight", return_value=1): + with patch("cron.scheduler.get_running_job_ids", return_value=frozenset({"job-1"})): _snapshot, timed_out = await runner._drain_active_agents(0.05) assert timed_out is True @@ -59,7 +59,7 @@ async def test_gateway_stop_waits_for_cron_before_final_tool_kill(): call_order: list[str] = [] def _cron_in_flight(): - return cron_count[0] + return frozenset(f"job-{i}" for i in range(cron_count[0])) def _fake_kill_all(task_id=None): call_order.append("kill_all") @@ -70,7 +70,7 @@ async def test_gateway_stop_waits_for_cron_before_final_tool_kill(): cron_count[0] = 0 with ( - patch("cron.scheduler.cron_jobs_in_flight", side_effect=_cron_in_flight), + patch("cron.scheduler.get_running_job_ids", side_effect=_cron_in_flight), patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"), patch("agent.auxiliary_client.shutdown_cached_clients"), From f5ef7ee9da66cff764296ed9da9c69b70105a90f Mon Sep 17 00:00:00 2001 From: AIalliAI <285906080+AIalliAI@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:47:26 +0000 Subject: [PATCH 201/610] fix(tui): prevent ws_orphan_reap from ending gateway-originated sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard _finalize_session's db.end_session() call against gateway-owned sessions (telegram, bluebubbles, discord, etc.). The TUI is a viewer for these sessions, not the lifecycle owner. Unconditionally ending them in state.db creates a Groundhog Day routing loop: the gateway's #54878 self-heal detects the stale entry, recovers to the parent session, context compression splits back to the reaped child, and the cycle repeats on every inbound message — causing complete conversational context amnesia. Fixes #60609 --- tui_gateway/server.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index b5fa98579466..fcd29723bd0d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -586,7 +586,23 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No try: db = _get_db() if db is not None: - db.end_session(session_id, end_reason) + # Don't end gateway-originated sessions — the gateway owns their + # lifecycle. The TUI is a viewer, not the owner. Ending a + # gateway session in state.db triggers a Groundhog Day routing + # loop: the gateway's #54878 self-heal detects the stale entry, + # recovers to the parent session, context compression splits + # back to the reaped child, and the cycle repeats on every + # inbound message. (#60609) + _GATEWAY_SOURCES = frozenset({ + "bluebubbles", "telegram", "discord", "signal", + "whatsapp", "sms", "slack", "mattermost", + "matrix", "line", "wechat", "facebook", + "imessage", "googlechat", + }) + row = db.get_session(session_id) + source = (row or {}).get("source", "") + if source not in _GATEWAY_SOURCES: + db.end_session(session_id, end_reason) except Exception: pass From 48788032da2e88f0a010791a61667539272df65b Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:40:14 -0700 Subject: [PATCH 202/610] fix(tui): derive gateway-owned sources from the Platform enum, not a hardcoded list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The salvaged guard used a hand-maintained frozenset of 14 platform names — several of which (line, wechat, facebook, imessage, googlechat) aren't actual Hermes Platform values, while real ones (whatsapp_cloud, feishu, wecom, dingtalk, qqbot, yuanbao, plugin platforms like irc) were missing. Resolve the source through gateway.config.Platform instead (built-ins + registered plugin platforms via _missing_), with an explicit exclusion set for self-owned/local sources. Adds tests for the guard and both reap paths. --- .../test_gateway_owned_session_reap.py | 83 +++++++++++++++++++ tui_gateway/server.py | 58 +++++++++---- 2 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 tests/tui_gateway/test_gateway_owned_session_reap.py diff --git a/tests/tui_gateway/test_gateway_owned_session_reap.py b/tests/tui_gateway/test_gateway_owned_session_reap.py new file mode 100644 index 000000000000..81edd590f464 --- /dev/null +++ b/tests/tui_gateway/test_gateway_owned_session_reap.py @@ -0,0 +1,83 @@ +"""Tests for #60609: the TUI backend must not end gateway-owned sessions. + +``_finalize_session`` (and thus the ws-orphan reaper / session.close paths +that funnel into it) marks the session ended in state.db. For sessions the +messaging gateway owns (telegram, discord, ...), that write creates the +Groundhog Day routing loop described in #60609 — the gateway self-heal +drops the ended-but-routed entry, recovers hours-old parent context, and +loops. The TUI is only a viewer of those sessions. +""" + +from unittest.mock import MagicMock, patch + +from tui_gateway.server import _finalize_session, _is_gateway_owned_source + + +class TestIsGatewayOwnedSource: + def test_builtin_gateway_platforms_are_owned(self): + for src in ("telegram", "discord", "whatsapp", "slack", "signal", + "matrix", "mattermost", "bluebubbles", "sms", "email"): + assert _is_gateway_owned_source(src) is True, src + + def test_case_and_whitespace_normalized(self): + assert _is_gateway_owned_source(" Telegram ") is True + + def test_tui_owned_sources_are_not(self): + for src in ("tui", "cli", "webui", "desktop", "cron", "subagent", + "test", "acp", ""): + assert _is_gateway_owned_source(src) is False, src + + def test_local_and_server_endpoints_are_not(self): + # Platform enum members, but their sessions aren't owned by a remote + # chat surface — reaping them keeps /resume clean. + for src in ("local", "webhook", "api_server", "msgraph_webhook"): + assert _is_gateway_owned_source(src) is False, src + + def test_arbitrary_strings_are_not(self): + assert _is_gateway_owned_source("hermesbench-task-xyz") is False + assert _is_gateway_owned_source(None) is False + + +def _make_session(session_id="sess_1"): + agent = MagicMock() + agent.session_id = session_id + return { + "agent": agent, + "history": [{"role": "user", "content": "x"}], + "history_lock": None, + "session_key": session_id, + } + + +class TestFinalizeSkipsGatewaySessions: + @patch("tui_gateway.server._get_db") + def test_gateway_session_not_ended(self, mock_get_db): + db = MagicMock() + db.get_session.return_value = {"id": "sess_1", "source": "telegram"} + mock_get_db.return_value = db + + _finalize_session(_make_session(), end_reason="ws_orphan_reap") + + db.end_session.assert_not_called() + + @patch("tui_gateway.server._get_db") + def test_tui_session_still_ended(self, mock_get_db): + db = MagicMock() + db.get_session.return_value = {"id": "sess_1", "source": "tui"} + mock_get_db.return_value = db + + _finalize_session(_make_session(), end_reason="ws_orphan_reap") + + db.end_session.assert_called_once_with("sess_1", "ws_orphan_reap") + + @patch("tui_gateway.server._get_db") + def test_missing_row_still_ended(self, mock_get_db): + """A session with no state.db row can't be gateway-owned — keep the + pre-existing reap behavior.""" + db = MagicMock() + db.get_session.return_value = None + mock_get_db.return_value = db + + _finalize_session(_make_session(), end_reason="tui_close") + + db.end_session.assert_called_once_with("sess_1", "tui_close") diff --git a/tui_gateway/server.py b/tui_gateway/server.py index fcd29723bd0d..412c89aaf154 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -499,6 +499,42 @@ def _transfer_active_session_slot( return False +# Session sources the TUI/desktop backend must never end in state.db: the +# messaging gateway owns those sessions' lifecycle — the TUI is only a viewer +# (a resume of a Telegram/Discord/... session). Ending one creates the +# #60609 Groundhog Day routing loop (see _finalize_session). Sources the +# TUI backend itself creates ("tui", plus whatever a client passes as its +# own ``source``) and the CLI's own sessions are NOT gateway-owned. +_NON_GATEWAY_SOURCES = frozenset({ + "", "tui", "cli", "webui", "desktop", "cron", "subagent", "test", + "local", "acp", "webhook", "api_server", "msgraph_webhook", +}) + + +def _is_gateway_owned_source(source: str) -> bool: + """True when ``source`` names a messaging-gateway platform whose session + lifecycle belongs to the gateway, not to this TUI backend. + + Structural rather than a hardcoded platform list: any source that + resolves to a known gateway ``Platform`` (built-in enum member OR a + registered platform plugin, via ``Platform._missing_``) counts, so new + platforms are covered automatically. Local/self-owned sources are + excluded explicitly — ``local``/``webhook``/``api_server`` are Platform + members but their sessions are not owned by a remote chat surface that + routes by session_key, so reaping them is safe and keeps /resume clean. + """ + src = (source or "").strip().lower() + if src in _NON_GATEWAY_SOURCES: + return False + try: + from gateway.config import Platform + + Platform(src) # raises ValueError for arbitrary non-platform strings + return True + except Exception: + return False + + def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> None: """Best-effort finalize hook + memory commit for a session. @@ -586,22 +622,16 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No try: db = _get_db() if db is not None: - # Don't end gateway-originated sessions — the gateway owns their - # lifecycle. The TUI is a viewer, not the owner. Ending a - # gateway session in state.db triggers a Groundhog Day routing - # loop: the gateway's #54878 self-heal detects the stale entry, - # recovers to the parent session, context compression splits - # back to the reaped child, and the cycle repeats on every - # inbound message. (#60609) - _GATEWAY_SOURCES = frozenset({ - "bluebubbles", "telegram", "discord", "signal", - "whatsapp", "sms", "slack", "mattermost", - "matrix", "line", "wechat", "facebook", - "imessage", "googlechat", - }) + # Don't end gateway-originated sessions — the gateway owns + # their lifecycle. The TUI is a viewer, not the owner. + # Ending a gateway session in state.db triggers a Groundhog + # Day routing loop: the gateway's #54878 self-heal detects + # the stale entry, recovers to the parent session, context + # compression splits back to the reaped child, and the cycle + # repeats on every inbound message. (#60609) row = db.get_session(session_id) source = (row or {}).get("source", "") - if source not in _GATEWAY_SOURCES: + if not _is_gateway_owned_source(source): db.end_session(session_id, end_reason) except Exception: pass From f64e4f4f5768c18a53f44890747653bafcab2796 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Wed, 8 Jul 2026 16:55:32 +1000 Subject: [PATCH 203/610] feat(gateway): generic OIDC client-credentials relay provisioning (NAS-free) (#60730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For air-gapped / self-hosted-IdP deploys with NO Nous Portal, let the gateway obtain its caller-identity bearer from a generic OAuth2 client_credentials grant against the operator's own IdP (e.g. Microsoft Entra ID) instead of only resolve_nous_access_token(). The connector's OIDC tenant resolver reads a claim (default tid) off that token as the tenant. - gateway/relay: new canonical _resolve_relay_identity_token() — client_credentials when gateway.idp.token_url (or GATEWAY_RELAY_IDP_* env) is set, else Nous Portal (unchanged default). Wired into self_provision_relay(). - hermes_cli/gateway_enroll: _resolve_identity_token() delegates to the canonical resolver so the enroll CLI and the runtime self-provision path share ONE impl. Config via gateway.idp.{token_url,client_id,client_secret,scope} in config.yaml (env override GATEWAY_RELAY_IDP_*). No behaviour change when unset. Tests: tests/gateway/relay/test_identity_token_resolver.py (6 — mode selection, request shape, config/env precedence, fail-closed). Relay suite 162 pass. Validated via the cross-repo gateway<->connector live E2E (provision, managed self-provision, inbound round-trip, /link) against a connector running the OIDC tenant resolver with zero NAS config. --- gateway/relay/__init__.py | 84 +++++++++- hermes_cli/gateway_enroll.py | 23 ++- .../relay/test_identity_token_resolver.py | 143 ++++++++++++++++++ 3 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 tests/gateway/relay/test_identity_token_resolver.py diff --git a/gateway/relay/__init__.py b/gateway/relay/__init__.py index 74f8858ad02e..0c64aaedeb5a 100644 --- a/gateway/relay/__init__.py +++ b/gateway/relay/__init__.py @@ -436,6 +436,80 @@ def _post_provision( return payload +def _resolve_relay_identity_token() -> str: + """Resolve the caller-identity bearer token the connector introspects to a tenant. + + Canonical resolver shared by the runtime self-provision path and the + ``hermes gateway enroll`` CLI. Two modes, in precedence order: + + 1. **Generic OIDC client-credentials** (air-gapped / self-hosted-IdP, NO + Nous Portal): when ``gateway.idp.token_url`` (or + ``GATEWAY_RELAY_IDP_TOKEN_URL``) is configured, obtain a workload access + token via the OAuth2 ``client_credentials`` grant against the operator's + own IdP (Entra; Authentik in the sandbox). The connector's Seam-A OIDC + verifier reads a claim (default ``tid``) off it as the tenant. + 2. **Nous Portal** (default): ``resolve_nous_access_token()`` — existing + managed/hosted behaviour. + + Raises on failure; callers decide whether that's fatal (enroll CLI) or a + graceful boot no-op (self-provision). + """ + token_url = os.environ.get("GATEWAY_RELAY_IDP_TOKEN_URL", "").strip() + client_id = os.environ.get("GATEWAY_RELAY_IDP_CLIENT_ID", "").strip() + client_secret = os.environ.get("GATEWAY_RELAY_IDP_CLIENT_SECRET", "").strip() + scope = os.environ.get("GATEWAY_RELAY_IDP_SCOPE", "").strip() + if not token_url: + try: + from gateway.run import _load_gateway_config # late import to avoid cycle + + idp = ((_load_gateway_config().get("gateway") or {}).get("idp") or {}) + token_url = str(idp.get("token_url", "") or "").strip() + client_id = client_id or str(idp.get("client_id", "") or "").strip() + client_secret = client_secret or str(idp.get("client_secret", "") or "").strip() + scope = scope or str(idp.get("scope", "") or "").strip() + except Exception: # noqa: BLE001 - config absence must not crash + token_url = token_url or "" + + if not token_url: + # Mode 2 — Nous Portal (default, unchanged behaviour). + from hermes_cli.auth import resolve_nous_access_token + + return resolve_nous_access_token() + + # Mode 1 — generic OAuth2 client_credentials grant. + import json + import urllib.error + import urllib.parse + import urllib.request + + if not client_id or not client_secret: + raise RuntimeError( + "gateway.idp.token_url configured but client_id/client_secret missing" + ) + form = { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + } + if scope: + form["scope"] = scope + req = urllib.request.Request( + token_url, + data=urllib.parse.urlencode(form).encode("utf-8"), + method="POST", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + ) + with urllib.request.urlopen(req, timeout=15.0) as resp: + payload = json.loads(resp.read().decode()) + access_token = (payload or {}).get("access_token") + if not isinstance(access_token, str) or not access_token.strip(): + raise RuntimeError("IdP client_credentials response had no access_token") + return access_token.strip() + + def self_provision_relay() -> bool: """Boot-time relay self-provision: mint relay creds in-process, no human, no disk. @@ -489,13 +563,11 @@ def self_provision_relay() -> bool: return False try: - from hermes_cli.auth import resolve_nous_access_token - - access_token = resolve_nous_access_token() + access_token = _resolve_relay_identity_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) + # No resolvable identity (e.g. a self-hosted box that hasn't enrolled and + # configured no IdP) -> nothing to provision with; skip quietly and boot. + logger.warning("relay self-provision skipped: could not resolve identity token (%s)", exc) return False identities = relay_platform_identities() diff --git a/hermes_cli/gateway_enroll.py b/hermes_cli/gateway_enroll.py index 88c7dfde566b..975db9ff80aa 100644 --- a/hermes_cli/gateway_enroll.py +++ b/hermes_cli/gateway_enroll.py @@ -35,6 +35,7 @@ import os import socket import sys import urllib.error +import urllib.parse import urllib.request from typing import Optional @@ -85,6 +86,20 @@ def _resolve_connector_url(override: Optional[str]) -> Optional[str]: return raw +def _resolve_identity_token() -> str: + """Resolve the caller-identity bearer token (generic-OIDC or Nous Portal). + + Delegates to the canonical resolver in ``gateway.relay`` so the enroll CLI and + the runtime self-provision path share ONE implementation (generic OAuth2 + client-credentials when ``gateway.idp.token_url`` is set — the air-gapped / + self-hosted-IdP path; otherwise Nous Portal). Raises RuntimeError on failure. + """ + from gateway.relay import _resolve_relay_identity_token + + return _resolve_relay_identity_token() + + + def _post_enroll( *, connector_base_url: str, @@ -179,9 +194,11 @@ def cmd_gateway_enroll(args) -> None: gateway_id = (getattr(args, "gateway_id", None) or _default_gateway_id()).strip() - # 1. Resolve a fresh Nous access token (the tenant-proving identity). + # 1. Resolve the caller-identity token (the tenant-proving identity). Generic + # OIDC client-credentials when an IdP token endpoint is configured (air- + # gapped / self-hosted-IdP, NO Nous Portal); otherwise the Nous Portal token. try: - access_token = resolve_nous_access_token() + access_token = _resolve_identity_token() except AuthError as exc: if getattr(exc, "relogin_required", False): print("✗ You're not logged into Nous Portal.") @@ -190,7 +207,7 @@ def cmd_gateway_enroll(args) -> None: print(f"✗ Could not resolve a Nous Portal access token: {exc}") sys.exit(1) except Exception as exc: - print(f"✗ Could not resolve a Nous Portal access token: {exc}") + print(f"✗ Could not resolve a caller-identity token: {exc}") sys.exit(1) # 2-3. Redeem the enrollment token at the connector. diff --git a/tests/gateway/relay/test_identity_token_resolver.py b/tests/gateway/relay/test_identity_token_resolver.py new file mode 100644 index 000000000000..d803be1c8143 --- /dev/null +++ b/tests/gateway/relay/test_identity_token_resolver.py @@ -0,0 +1,143 @@ +"""Unit tests for the generic-OIDC / Nous-Portal caller-identity token resolver. + +Covers gateway.relay._resolve_relay_identity_token() — the canonical resolver +shared by the runtime self-provision path and the `hermes gateway enroll` CLI. + +Two modes: + 1. Generic OAuth2 client_credentials when gateway.idp.token_url (or + GATEWAY_RELAY_IDP_TOKEN_URL) is configured (air-gapped / self-hosted-IdP). + 2. Nous Portal (resolve_nous_access_token) otherwise — the default. + +The HTTP POST and the Nous resolver are monkeypatched; these prove the mode +SELECTION, the client_credentials request shape, and the fail-closed paths. +""" + +from __future__ import annotations + +import io +import json + +import pytest + +import gateway.relay as relay + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + for k in ( + "GATEWAY_RELAY_IDP_TOKEN_URL", + "GATEWAY_RELAY_IDP_CLIENT_ID", + "GATEWAY_RELAY_IDP_CLIENT_SECRET", + "GATEWAY_RELAY_IDP_SCOPE", + ): + monkeypatch.delenv(k, raising=False) + # Never read config.yaml off disk by default. + monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {}, raising=False) + + +def test_defaults_to_nous_portal_when_no_idp_configured(monkeypatch): + called = {} + + def fake_resolve(): + called["yes"] = True + return "nous-portal-token" + + monkeypatch.setattr( + "hermes_cli.auth.resolve_nous_access_token", fake_resolve, raising=False + ) + assert relay._resolve_relay_identity_token() == "nous-portal-token" + assert called == {"yes": True} + + +def test_client_credentials_via_env(monkeypatch): + monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://idp.test/token") + monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_ID", "agent-client") + monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_SECRET", "shh") + monkeypatch.setenv("GATEWAY_RELAY_IDP_SCOPE", "connector.provision") + + captured = {} + + def fake_urlopen(req, timeout=None): + captured["url"] = req.full_url + captured["method"] = req.get_method() + captured["body"] = req.data.decode() + captured["headers"] = {k.lower(): v for k, v in req.headers.items()} + return io.BytesIO(json.dumps({"access_token": "idp-workload-token"}).encode()) + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + + token = relay._resolve_relay_identity_token() + assert token == "idp-workload-token" + assert captured["url"] == "https://idp.test/token" + assert captured["method"] == "POST" + # client_credentials grant, form-encoded, with all fields. + assert "grant_type=client_credentials" in captured["body"] + assert "client_id=agent-client" in captured["body"] + assert "client_secret=shh" in captured["body"] + assert "scope=connector.provision" in captured["body"] + assert captured["headers"]["content-type"] == "application/x-www-form-urlencoded" + + +def test_client_credentials_via_config_yaml(monkeypatch): + monkeypatch.setattr( + "gateway.run._load_gateway_config", + lambda: { + "gateway": { + "idp": { + "token_url": "https://idp.test/token", + "client_id": "cfg-client", + "client_secret": "cfg-secret", + } + } + }, + raising=False, + ) + + def fake_urlopen(req, timeout=None): + body = req.data.decode() + assert "client_id=cfg-client" in body + assert "client_secret=cfg-secret" in body + # No scope configured -> not sent. + assert "scope=" not in body + return io.BytesIO(json.dumps({"access_token": "cfg-token"}).encode()) + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + assert relay._resolve_relay_identity_token() == "cfg-token" + + +def test_env_token_url_takes_precedence_over_config(monkeypatch): + monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://env.test/token") + monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_ID", "env-client") + monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_SECRET", "env-secret") + monkeypatch.setattr( + "gateway.run._load_gateway_config", + lambda: {"gateway": {"idp": {"token_url": "https://cfg.test/token"}}}, + raising=False, + ) + + def fake_urlopen(req, timeout=None): + assert req.full_url == "https://env.test/token" + return io.BytesIO(json.dumps({"access_token": "t"}).encode()) + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + assert relay._resolve_relay_identity_token() == "t" + + +def test_raises_when_client_creds_missing(monkeypatch): + monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://idp.test/token") + # No client_id / client_secret. + with pytest.raises(RuntimeError, match="client_id/client_secret missing"): + relay._resolve_relay_identity_token() + + +def test_raises_when_no_access_token_in_response(monkeypatch): + monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://idp.test/token") + monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_ID", "c") + monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_SECRET", "s") + + def fake_urlopen(req, timeout=None): + return io.BytesIO(json.dumps({"token_type": "Bearer"}).encode()) + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + with pytest.raises(RuntimeError, match="no access_token"): + relay._resolve_relay_identity_token() From 8eac52054bf33f4362552ace09dcca5b4e1b47dc Mon Sep 17 00:00:00 2001 From: giggling-ginger <110955495+giggling-ginger@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:34:00 +0000 Subject: [PATCH 204/610] fix(desktop): keep configured MoA presets in model picker --- hermes_cli/inventory.py | 50 +++++++++++++++++++++++++++++- tests/hermes_cli/test_inventory.py | 38 +++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 08cfe322b878..d283f2a10138 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -340,13 +340,61 @@ def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list if slug == "moa": # MoA is a virtual routing mode, not an independently configured # provider. Hide it from explicit-only pickers unless it is the - # current provider (handled above). + # current provider (handled above) or the user explicitly wrote an + # enabled MoA preset into config.yaml. Use raw config so the + # DEFAULT_CONFIG preset does not make every desktop picker show MoA. + if _raw_config_has_enabled_moa_preset(): + kept.append(row) continue if is_provider_explicitly_configured(slug): kept.append(row) return kept +def _raw_config_has_enabled_moa_preset() -> bool: + """Return True when the user's raw config explicitly enables MoA. + + ``load_config()`` includes ``DEFAULT_CONFIG["moa"].presets.default`` for + everyone. Explicit-only model pickers must not treat that default as a user + choice, but they should keep MoA visible once the user has saved at least + one enabled preset (or an older flat MoA config) in their own config.yaml. + """ + try: + from hermes_cli.config import read_raw_config + + raw = read_raw_config() + except Exception: + return False + + if not isinstance(raw, dict): + return False + moa = raw.get("moa") + if not isinstance(moa, dict): + return False + + presets = moa.get("presets") + if isinstance(presets, dict): + for name, preset in presets.items(): + if not str(name or "").strip(): + continue + if not isinstance(preset, dict): + return True + if preset.get("enabled", True): + return True + return False + + legacy_keys = { + "reference_models", + "aggregator", + "reference_temperature", + "aggregator_temperature", + "max_tokens", + "reference_max_tokens", + "fanout", + } + return any(key in moa for key in legacy_keys) and bool(moa.get("enabled", True)) + + def _apply_picker_hints(rows: list[dict]) -> None: """Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row. diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index 34c61aa13074..4167fd92b839 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -402,6 +402,7 @@ def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_ ctx = _empty_ctx(provider="openai-codex", model="gpt-5.4") with ( _list_auth_returning(rows), + patch("hermes_cli.config.read_raw_config", return_value={}), patch( "hermes_cli.auth.is_provider_explicitly_configured", side_effect=lambda slug: slug == "gemini", @@ -416,6 +417,43 @@ def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_ ] +def test_explicit_only_keeps_moa_when_raw_config_has_enabled_preset(): + rows = [ + {"slug": "moa", "name": "MoA", "models": ["review"], + "total_models": 1, "is_current": False, "is_user_defined": False, + "source": "virtual"}, + ] + ctx = _empty_ctx(provider="openrouter", model="anthropic/claude-opus-4.8") + raw_config = { + "moa": { + "active_preset": "review", + "presets": { + "review": { + "enabled": True, + "reference_models": [ + {"provider": "openai-codex", "model": "gpt-5.5"}, + ], + "aggregator": { + "provider": "openrouter", + "model": "anthropic/claude-opus-4.8", + }, + }, + }, + }, + } + + with ( + _list_auth_returning(rows), + patch("hermes_cli.config.load_config", return_value=raw_config), + patch("hermes_cli.config.read_raw_config", return_value=raw_config), + patch("hermes_cli.auth.is_provider_explicitly_configured", return_value=False), + ): + payload = build_models_payload(ctx, explicit_only=True) + + assert [row["slug"] for row in payload["providers"]] == ["moa"] + assert payload["providers"][0]["models"] == ["review"] + + # ─── picker_hints ────────────────────────────────────────────────────── From 832c5f9bc9018b5540c13cbc35805cc49ce8b073 Mon Sep 17 00:00:00 2001 From: veradim Date: Sun, 7 Jun 2026 16:33:00 +0500 Subject: [PATCH 205/610] Fix slow Z.AI startup by caching auto-detected endpoint to disk (cherry picked from commit 6ed884933a178d5540f02d80e3fe9e678ca844eb) --- hermes_cli/auth.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 179d8697e9d5..961afd0b4dbd 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -708,7 +708,13 @@ def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) -> "label": detected.get("label", ""), "key_hash": key_hash, } - _save_provider_state(auth_store, "zai", state) + with _auth_store_lock(): + # Reload auth_store under lock to avoid overwriting concurrent changes + auth_store = _load_auth_store() + state_under_lock = _load_provider_state(auth_store, "zai") or {} + state_under_lock["detected_endpoint"] = state["detected_endpoint"] + _save_provider_state(auth_store, "zai", state_under_lock) + _save_auth_store(auth_store) logger.info("Z.AI: auto-detected endpoint %s (%s)", detected["label"], detected["base_url"]) return detected["base_url"] From c75e1d1b876086bbf13c29048424bf35416b991e Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:04:44 +0530 Subject: [PATCH 206/610] chore: add veradim to AUTHOR_MAP for PR #41201 salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index a07a268db378..5e654bcab381 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -890,6 +890,7 @@ AUTHOR_MAP = { "kshitijk4poor@gmail.com": "kshitijk4poor", "1294707+Tosko4@users.noreply.github.com": "Tosko4", "keira.voss94@gmail.com": "keiravoss94", + "vadim.veroslavov@mail.ru": "veradim", # PR #41201 salvage (Z.AI endpoint persist) "16443023+stablegenius49@users.noreply.github.com": "stablegenius49", "fqsy1416@gmail.com": "EKKOLearnAI", "octo-patch@github.com": "octo-patch", From 6eeed3f1e8847a352ae1090450a71f0975a3d3a9 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:07:52 +0530 Subject: [PATCH 207/610] fix: don't flip active_provider when caching Z.AI probe result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _save_provider_state() sets auth_store['active_provider'] as a side effect. The Z.AI endpoint probe runs from credential-pool env seeding for any user with a Z.AI key in env — persisting the probe cache must not silently make zai the active provider. Use _store_provider_state(set_active=False). Follow-up to PR #41201 salvage. --- hermes_cli/auth.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 961afd0b4dbd..103c151e6759 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -713,7 +713,10 @@ def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) -> auth_store = _load_auth_store() state_under_lock = _load_provider_state(auth_store, "zai") or {} state_under_lock["detected_endpoint"] = state["detected_endpoint"] - _save_provider_state(auth_store, "zai", state_under_lock) + # set_active=False: this runs from credential-pool env seeding + # (agent/credential_pool.py) for ANY user with a Z.AI key in env, + # and caching a probe result must not flip their active provider. + _store_provider_state(auth_store, "zai", state_under_lock, set_active=False) _save_auth_store(auth_store) logger.info("Z.AI: auto-detected endpoint %s (%s)", detected["label"], detected["base_url"]) return detected["base_url"] From 1192f29450f1dc440d44b094817977f00475643c Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:27:52 +0530 Subject: [PATCH 208/610] fix: Z.AI endpoint persist failure must not break URL resolution Review findings (hermes-pr-review Phase 2, 3-angle): - _save_auth_store() does real filesystem I/O (mkdir, O_EXCL create, fsync, atomic replace) and can raise on disk-full/permissions/lock-timeout. The persist ran bare in the success path, so a persist failure aborted _resolve_zai_base_url() after detection had already succeeded. Wrap the persist in try/except: log a warning and still return the detected URL (worst case: next start re-probes). - Readability: stage the payload in a local detected_endpoint instead of writing through the stale pre-lock 'state' dict, which is no longer what gets persisted. --- hermes_cli/auth.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 103c151e6759..5cf6d50186a3 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -701,23 +701,29 @@ def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) -> if detected and detected.get("base_url"): # Persist the detection result keyed on the API key hash. key_hash = hashlib.sha256(api_key.encode()).hexdigest()[:16] - state["detected_endpoint"] = { + detected_endpoint = { "base_url": detected["base_url"], "endpoint_id": detected.get("id", ""), "model": detected.get("model", ""), "label": detected.get("label", ""), "key_hash": key_hash, } - with _auth_store_lock(): - # Reload auth_store under lock to avoid overwriting concurrent changes - auth_store = _load_auth_store() - state_under_lock = _load_provider_state(auth_store, "zai") or {} - state_under_lock["detected_endpoint"] = state["detected_endpoint"] - # set_active=False: this runs from credential-pool env seeding - # (agent/credential_pool.py) for ANY user with a Z.AI key in env, - # and caching a probe result must not flip their active provider. - _store_provider_state(auth_store, "zai", state_under_lock, set_active=False) - _save_auth_store(auth_store) + # Persist failure (disk full, permissions, lock timeout) must not + # break resolution — detection already succeeded; worst case the + # next start re-probes. + try: + with _auth_store_lock(): + # Reload auth_store under lock to avoid overwriting concurrent changes + auth_store = _load_auth_store() + state_under_lock = _load_provider_state(auth_store, "zai") or {} + state_under_lock["detected_endpoint"] = detected_endpoint + # set_active=False: this runs from credential-pool env seeding + # (agent/credential_pool.py) for ANY user with a Z.AI key in env, + # and caching a probe result must not flip their active provider. + _store_provider_state(auth_store, "zai", state_under_lock, set_active=False) + _save_auth_store(auth_store) + except Exception as exc: + logger.warning("Z.AI: could not persist detected endpoint (%s); will re-probe next start", exc) logger.info("Z.AI: auto-detected endpoint %s (%s)", detected["label"], detected["base_url"]) return detected["base_url"] From 7ecc822e1165f5f4d274075a40066a8ab04214d0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Jul 2026 05:13:18 -0700 Subject: [PATCH 209/610] fix(cron): stop the ticker from stalling forever on a wedged jobs lock (#60703) (#60855) Three fixes for the silent post-restart ticker stall: 1. _jobs_lock() bounds its cross-process flock: LOCK_NB polled against a 30s deadline instead of an unbounded LOCK_EX taken while holding the process-wide RLock. On timeout it logs at ERROR and degrades to in-process-only locking (the existing fallback path), so a sibling process wedged while holding .jobs.lock can no longer freeze every cron function - including the ticker's get_due_jobs() and thus the heartbeat - forever with zero logging. 2. fire_claim/run_claim freshness checks are bounded on both sides (0 <= age < ttl): a claim stamped in the future (clock/TZ skew across a restart) was previously fresh forever, making the job permanently unfireable and every manual run report 'already being fired'. 3. _execute_job_now distinguishes paused/disabled/missing jobs from a genuinely held claim instead of mislabeling them all as 'already being fired'. --- cron/jobs.py | 61 ++++++++- tests/cron/test_ticker_stall_60703.py | 186 ++++++++++++++++++++++++++ tools/cronjob_tools.py | 16 ++- 3 files changed, 257 insertions(+), 6 deletions(-) create mode 100644 tests/cron/test_ticker_stall_60703.py diff --git a/cron/jobs.py b/cron/jobs.py index 7ba8fd5abec8..76cfc60cbf5d 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -84,6 +84,15 @@ TICKER_INTERVAL_SECONDS = 60 # concurrent mark_job_run / advance_next_run calls can clobber each other. _jobs_file_lock = threading.RLock() _jobs_lock_state = threading.local() + +# Upper bound on waiting for the cross-process .jobs.lock flock (#60703). +# Every cron function in the process funnels through _jobs_lock(), and the +# flock is taken while holding the process-wide RLock — so an unbounded wait +# on a lock held by a wedged sibling process silently freezes the ticker +# heartbeat and every job forever. 30s is orders of magnitude above any +# legitimate critical section (field updates only) while keeping the ticker's +# worst-case stall well under one status-alarm threshold. +_JOBS_LOCK_TIMEOUT_SECONDS = 30.0 OUTPUT_DIR = CRON_DIR / "output" ONESHOT_GRACE_SECONDS = 120 @@ -177,7 +186,42 @@ def _jobs_lock(): lock_fd = open(_jobs_lock_file(), "a+", encoding="utf-8") lock_fd.seek(0) if fcntl is not None: - fcntl.flock(lock_fd, fcntl.LOCK_EX) + # Bounded acquisition (#60703): a plain blocking + # fcntl.flock(LOCK_EX) here has NO timeout, and it is + # taken while holding the process-wide _jobs_file_lock + # RLock above. If another process wedges while holding + # .jobs.lock (e.g. an old gateway draining through a + # restart), a single blocked acquirer freezes EVERY cron + # function in this process — including the ticker's + # get_due_jobs() — silently and forever: the heartbeat + # file stops updating and all jobs stop firing with no + # error logged. Poll LOCK_NB against a deadline instead; + # on timeout, log loudly and fall through to the same + # in-process-only degraded mode used when locking is + # unavailable. A briefly-torn cross-process write is + # strictly better than a permanently dead scheduler. + _deadline = time.monotonic() + _JOBS_LOCK_TIMEOUT_SECONDS + while True: + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except (OSError, IOError): + if time.monotonic() >= _deadline: + logger.error( + "Timed out after %.0fs waiting for the cron " + "jobs lock (%s) — another process is holding " + "it. Proceeding with in-process locking only " + "so the scheduler stays alive (#60703).", + _JOBS_LOCK_TIMEOUT_SECONDS, + _jobs_lock_file(), + ) + try: + lock_fd.close() + except OSError: + pass + lock_fd = None + break + time.sleep(0.1) elif msvcrt is not None: getattr(msvcrt, "locking")(lock_fd.fileno(), getattr(msvcrt, "LK_LOCK"), 1) except (OSError, IOError) as e: @@ -1565,7 +1609,14 @@ def claim_job_for_fire(job_id: str, *, claim_ttl_seconds: int = 300) -> bool: if existing: try: claimed_at = _ensure_aware(datetime.fromisoformat(existing["at"])) - if (now - claimed_at).total_seconds() < claim_ttl_seconds: + # Bounded on BOTH sides (#60703): a claim stamped in the + # future (clock/TZ skew across a restart, or a corrupted + # timestamp) would otherwise have a negative age and stay + # "fresh" forever — the job becomes permanently unfireable + # and every manual `cron run` reports "already being + # fired". Treat future-dated claims as stale/overwritable. + _age = (now - claimed_at).total_seconds() + if 0 <= _age < claim_ttl_seconds: return False # someone holds a fresh claim except Exception: pass # malformed claim → overwrite @@ -1626,7 +1677,11 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]: claimed_at = _ensure_aware( datetime.fromisoformat(existing_claim["at"]) ) - if (now - claimed_at).total_seconds() < _run_claim_ttl: + # 0 <= age: a future-dated claim (clock/TZ skew across a + # restart) must be treated as stale, not eternally fresh, + # or the one-shot is skipped forever (#60703). + _age = (now - claimed_at).total_seconds() + if 0 <= _age < _run_claim_ttl: continue # a fresh claim is held by an in-flight run except (KeyError, ValueError, TypeError): pass # malformed claim → fall through and (re)claim diff --git a/tests/cron/test_ticker_stall_60703.py b/tests/cron/test_ticker_stall_60703.py new file mode 100644 index 000000000000..b4d4cca95208 --- /dev/null +++ b/tests/cron/test_ticker_stall_60703.py @@ -0,0 +1,186 @@ +"""Regression tests for #60703 — cron ticker silently stalls after gateway restart. + +Three fixes under test: + +1. ``_jobs_lock()`` bounds its cross-process flock: when another process holds + ``.jobs.lock`` indefinitely, acquisition times out, logs at ERROR, and falls + through to in-process-only locking — instead of blocking the calling thread + (and, transitively, the cron ticker heartbeat) forever. + +2. Claim freshness checks are bounded on both sides (``0 <= age < ttl``): a + ``fire_claim``/``run_claim`` stamped in the FUTURE (clock/TZ skew across a + restart) is treated as stale/overwritable, not eternally fresh. + +3. ``_execute_job_now`` no longer mislabels paused/disabled/missing jobs as + "already being fired". +""" + +import json +import os +import threading +import time +from datetime import timedelta +from pathlib import Path + +import pytest + +import cron.jobs as jobs_mod +from cron.jobs import ( + _jobs_lock, + claim_job_for_fire, + create_job, + get_due_jobs, + get_job, + load_jobs, + save_jobs, +) + + +try: + import fcntl +except ImportError: # pragma: no cover - non-POSIX + fcntl = None + + +pytestmark = pytest.mark.skipif(fcntl is None, reason="flock semantics are POSIX-only") + + +def _hold_jobs_flock(path: Path, release: threading.Event, held: threading.Event): + """Hold an exclusive flock on *path* from a separate fd until released. + + flock locks are per-open-file-description, so a second open() in the SAME + process contends exactly like another process would. + """ + fd = open(path, "a+", encoding="utf-8") + try: + fcntl.flock(fd, fcntl.LOCK_EX) + held.set() + release.wait(timeout=30) + finally: + try: + fcntl.flock(fd, fcntl.LOCK_UN) + except OSError: + pass + fd.close() + + +class TestBoundedJobsLock: + def test_lock_acquisition_times_out_and_degrades(self, monkeypatch, caplog): + """A foreign holder of .jobs.lock must NOT block _jobs_lock forever.""" + jobs_mod.ensure_dirs() + lock_path = jobs_mod._jobs_lock_file() + lock_path.touch() + + monkeypatch.setattr(jobs_mod, "_JOBS_LOCK_TIMEOUT_SECONDS", 1.0) + + release = threading.Event() + held = threading.Event() + holder = threading.Thread( + target=_hold_jobs_flock, args=(lock_path, release, held), daemon=True + ) + holder.start() + assert held.wait(timeout=10), "test holder failed to take the flock" + + try: + start = time.monotonic() + entered = False + with caplog.at_level("ERROR", logger="cron.jobs"): + with _jobs_lock(): + entered = True + elapsed = time.monotonic() - start + + assert entered, "critical section must still run in degraded mode" + assert elapsed < 10, f"lock wait was not bounded (took {elapsed:.1f}s)" + assert any("Timed out" in r.message for r in caplog.records), ( + "degraded-mode fallback must be logged at ERROR" + ) + finally: + release.set() + holder.join(timeout=10) + + def test_uncontended_lock_is_fast_and_silent(self, caplog): + jobs_mod.ensure_dirs() + start = time.monotonic() + with caplog.at_level("ERROR", logger="cron.jobs"): + with _jobs_lock(): + pass + assert time.monotonic() - start < 5 + assert not [r for r in caplog.records if "Timed out" in r.message] + + def test_reentrant_nesting_still_works(self): + with _jobs_lock(): + with _jobs_lock(): # must not deadlock or re-flock + pass + + +class TestFutureDatedClaims: + def _make_job(self, **kw): + return create_job(name="claim job", schedule="0 7 * * *", prompt="x", **kw) + + def test_future_fire_claim_is_treated_as_stale(self): + """A fire_claim stamped in the future must not block claiming forever.""" + job = self._make_job() + jobs = load_jobs() + for j in jobs: + if j["id"] == job["id"]: + future = jobs_mod._hermes_now() + timedelta(hours=6) + j["fire_claim"] = {"at": future.isoformat(), "by": "other-host:1"} + save_jobs(jobs) + + assert claim_job_for_fire(job["id"]) is True, ( + "future-dated claim must be overwritable, not eternally fresh" + ) + + def test_fresh_past_fire_claim_still_blocks(self): + job = self._make_job() + assert claim_job_for_fire(job["id"]) is True + # Immediately re-claiming must be refused — claim is genuinely fresh. + assert claim_job_for_fire(job["id"]) is False + + def test_expired_fire_claim_is_reclaimable(self): + job = self._make_job() + jobs = load_jobs() + for j in jobs: + if j["id"] == job["id"]: + past = jobs_mod._hermes_now() - timedelta(hours=6) + j["fire_claim"] = {"at": past.isoformat(), "by": "other-host:1"} + save_jobs(jobs) + assert claim_job_for_fire(job["id"]) is True + + def test_future_run_claim_does_not_skip_oneshot_forever(self): + """A one-shot with a future-dated run_claim must still become due.""" + past_fire = (jobs_mod._hermes_now() - timedelta(seconds=30)).isoformat() + job = create_job(name="oneshot", schedule=past_fire, prompt="x") + jobs = load_jobs() + for j in jobs: + if j["id"] == job["id"]: + future = jobs_mod._hermes_now() + timedelta(hours=6) + j["run_claim"] = {"at": future.isoformat(), "by": "other-host:1"} + j["next_run_at"] = past_fire + save_jobs(jobs) + + due_ids = {j["id"] for j in get_due_jobs()} + assert job["id"] in due_ids, ( + "future-dated run_claim must be treated as stale, not fresh" + ) + + +class TestHonestRunSkipMessages: + def test_paused_job_not_reported_as_already_firing(self): + from tools.cronjob_tools import _execute_job_now + + job = create_job(name="paused job", schedule="0 7 * * *", prompt="x") + from cron.jobs import pause_job + + pause_job(job["id"]) + res = _execute_job_now(get_job(job["id"])) + assert res["claimed"] is False + assert "paused" in (res["error"] or "").lower() + assert "already being fired" not in (res["error"] or "").lower() + + def test_missing_job_not_reported_as_already_firing(self): + from tools.cronjob_tools import _execute_job_now + + res = _execute_job_now({"id": "does-not-exist-123"}) + assert res["claimed"] is False + assert "no longer exists" in (res["error"] or "").lower() diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 02ac58f9c608..430cd1dda401 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -623,8 +623,18 @@ def _execute_job_now(job: Dict[str, Any]) -> Dict[str, Any]: # At-most-once claim: bail without running if a tick/other fire owns it. if not claim_job_for_fire(job_id): - return {"claimed": False, "success": False, - "error": "Job is already being fired by the scheduler; not run again."} + # claim_job_for_fire returns False for paused/disabled/missing + # jobs too — don't mislabel those as "already being fired" + # (#60703): that message sends the user chasing a phantom + # in-flight run when the job simply isn't runnable. + refreshed = get_job(job_id) + if refreshed is None: + reason = "Job no longer exists; nothing to run." + elif not refreshed.get("enabled", True) or refreshed.get("state") == "paused": + reason = "Job is paused/disabled; resume it before running." + else: + reason = "Job is already being fired by the scheduler; not run again." + return {"claimed": False, "success": False, "error": reason} # run_one_job records last_run_at/last_status via mark_job_run (which # also clears the fire claim) and returns True iff it processed the job. @@ -837,7 +847,7 @@ def cronjob( result["executed"] = exec_result.get("claimed", False) result["execution_success"] = exec_result.get("success", False) if not exec_result.get("claimed", False): - result["execution_skipped"] = ( + result["execution_skipped"] = exec_result.get("error") or ( "Already being fired by the scheduler; not run again." ) elif exec_result.get("error"): From 98804dbeef91c5f1ef517817c8f81dd2aeec523e Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Thu, 2 Jul 2026 21:32:08 -0300 Subject: [PATCH 210/610] fix(tui_gateway): back off notification poller when session is busy The busy-session branch of _notification_poller_loop re-queued the completion event and immediately re-polled it with no sleep, spinning at full speed (100% CPU, ~1100 futex/s of GIL churn) for as long as the session stayed running. This starved the dashboard asyncio loop: /api/status went from 0.14s to 3-6s with 10s timeouts. Sleep 0.25s outside history_lock before re-polling, mirroring the 0.1s back-off already used for foreign-session events. --- tui_gateway/server.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 412c89aaf154..fb98f615fa30 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -8487,11 +8487,19 @@ def _notification_poller_loop( _emit("status.update", sid, {"kind": "process", "text": text}) _emitted.add(_dedup_key) + _requeued = False with session["history_lock"]: if session.get("running"): process_registry.completion_queue.put(evt) - continue - session["running"] = True + _requeued = True + else: + session["running"] = True + if _requeued: + # Back off before re-polling: the re-queued event keeps the queue + # non-empty, so without a sleep this loop spins at full speed + # (100% CPU, GIL churn) for as long as the session stays busy. + time.sleep(0.25) + continue rid = f"__notif__{int(time.time() * 1000)}" try: From 5413c42f2cb8e9421b4ad1b5696ca3ff2a6f1bcb Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Jul 2026 04:23:01 -0700 Subject: [PATCH 211/610] chore: add SiteupAgencia to AUTHOR_MAP for #57435 salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 5e654bcab381..51b2d93b75cb 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "contato@siteup.com.br": "SiteupAgencia", # PR #57435 salvage (tui_gateway: back off notification poller when session is busy; #55578) "164521089+rainbowgits@users.noreply.github.com": "rainbowgore", # PR #59405 salvage (mcp: bound stdio initialize handshake to stop subprocess/FD leak; #59349) "sage@Sages-Mac-mini.local": "thestudionorth", # PR #60015 salvage (mcp: parent-death watchdog for stdio children; commit under unlinked local identity) "4087127+vampyren@users.noreply.github.com": "vampyren", # PR #59830 salvage (kanban: grab-to-pan board scrolling; original commit under unlinked local identity) From 465cbe8bb51dc3c812210eef535f4d79c5a7d276 Mon Sep 17 00:00:00 2001 From: waroffchange <116298975+waroffchange@users.noreply.github.com> Date: Wed, 8 Jul 2026 03:19:08 +0300 Subject: [PATCH 212/610] test(tools): add unit tests for skill_gist --- tests/tools/test_write_approval.py | 51 ++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/tools/test_write_approval.py b/tests/tools/test_write_approval.py index 73ea119e0e59..f2b8bab408b6 100644 --- a/tests/tools/test_write_approval.py +++ b/tests/tools/test_write_approval.py @@ -439,3 +439,54 @@ def test_memory_invalid_params_rejected_before_staging(hermes_home): r = json.loads(memory_tool("add", "memory", None, store=store)) assert r["success"] is False assert wa.pending_count("memory") == 0 + + +class TestSkillGist: + """skill_gist builds a heuristic one-line summary for a pending skill write. + + Pure, no model call — every branch is verifiable from the function source. + """ + + def test_create_with_frontmatter_description(self): + from tools import write_approval as wa + content = "---\ndescription: My cool skill\n---\nprint('hi')\n" + assert ( + wa.skill_gist("create", "demo", content=content) + == f"create 'demo' — My cool skill ({len(content)} chars)" + ) + + def test_edit_without_description_uses_size_only(self): + from tools import write_approval as wa + content = "no frontmatter here" + assert ( + wa.skill_gist("edit", "demo", content=content) + == f"rewrite 'demo' ({len(content)} chars)" + ) + + def test_large_content_reports_kb(self): + from tools import write_approval as wa + content = "x" * 2048 # >= 1024 bytes -> KB rounding + assert wa.skill_gist("create", "big", content=content) == "create 'big' (3 KB)" + + def test_create_without_content_falls_through(self): + from tools import write_approval as wa + assert wa.skill_gist("create", "demo") == "create 'demo'" + + def test_patch_counts_lines(self): + from tools import write_approval as wa + assert ( + wa.skill_gist("patch", "demo", file_path="SKILL.md", + old_string="a\nb", new_string="x\ny\nz") + == "patch 'demo' SKILL.md (+3/-2 lines)" + ) + + def test_patch_defaults_target_and_empty_strings(self): + from tools import write_approval as wa + assert wa.skill_gist("patch", "demo") == "patch 'demo' SKILL.md (+0/-0 lines)" + + def test_file_actions_and_unknown_fallback(self): + from tools import write_approval as wa + assert wa.skill_gist("write_file", "demo", file_path="a.py") == "write a.py in 'demo'" + assert wa.skill_gist("remove_file", "demo", file_path="a.py") == "remove a.py from 'demo'" + assert wa.skill_gist("delete", "demo") == "delete skill 'demo'" + assert wa.skill_gist("unknown", "demo") == "unknown 'demo'" From ac6dd598a4b8a76c044f5b4cde4ca354845c9f45 Mon Sep 17 00:00:00 2001 From: fyzanshaik Date: Sat, 4 Jul 2026 22:16:01 +0530 Subject: [PATCH 213/610] fix(agent): tag desktop chat sessions as desktop The desktop app's chat panel reuses tui_gateway as its backend, so every chat session was stamped platform="tui". That made the agent read terminal-specific platform guidance while running in the graphical desktop chat surface. Resolve the misclassification at its source: tui_gateway now picks platform="desktop" when HERMES_DESKTOP=1 and HERMES_DESKTOP_TERMINAL is unset, and keeps platform="tui" for the embedded terminal pane and standalone TUI. Add a PLATFORM_HINTS["desktop"] entry describing the actual chat surface (full GFM markdown, MEDIA: intercept, inline images). Move the embedded-pane clarifier to the platform-hint resolution site so it appends only to the tui hint under HERMES_DESKTOP_TERMINAL=1. Delete the now-dead desktop-hint block from build_environment_hints() that competed with the platform hint. Standalone TUI sessions produce byte-identical prompts as before; the new desktop hint and clarifier are assembled once per session in the stable tier, so prompt caching is preserved. --- agent/prompt_builder.py | 27 +- agent/system_prompt.py | 34 +++ .../hooks/use-prompt-actions/index.test.tsx | 4 +- .../session/hooks/use-prompt-actions/index.ts | 3 +- .../hooks/use-prompt-actions/submit.ts | 3 +- .../hooks/use-session-actions.test.tsx | 79 ++++++ .../hooks/use-session-actions/index.ts | 3 + tests/agent/test_platform_hint_desktop.py | 246 ++++++++++++++++++ tests/test_tui_gateway_server.py | 19 +- tests/tui_gateway/test_protocol.py | 10 +- .../test_session_platform_resolution.py | 147 +++++++++++ tui_gateway/server.py | 121 +++++++-- 12 files changed, 644 insertions(+), 52 deletions(-) create mode 100644 tests/agent/test_platform_hint_desktop.py create mode 100644 tests/tui_gateway/test_session_platform_resolution.py diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 3ec4a40b3929..abb70b543cbb 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -743,6 +743,17 @@ PLATFORM_HINTS = { "or 'all'). Do not promise the user that a deliver='origin' or " "default-deliver cron job will message them in this session." ), + "desktop": ( + "You are chatting inside the Hermes desktop app — a graphical chat " + "surface, not a terminal. Use markdown freely: it renders with full " + "GitHub flavor (tables, code blocks with syntax highlighting, math " + "via $...$, task lists, blockquote callouts). " + "You can deliver files natively — include MEDIA:/absolute/path/to/file " + "in your response. Images (.png, .jpg, .webp) appear inline, audio and " + "video play inline, and other files arrive as download links. You can " + "also include image URLs in markdown format ![alt](url) and they " + "render inline as photos." + ), "sms": ( "You are communicating via SMS. Keep responses concise and use plain text " "only — no markdown, no formatting. SMS messages are limited to ~1600 " @@ -1127,22 +1138,6 @@ def build_environment_hints() -> str: f"`uname -a && whoami && pwd`." ) - # Hermes desktop GUI — any agent running under the desktop app should know - # it. HERMES_DESKTOP marks the backend powering the chat; HERMES_DESKTOP_TERMINAL - # marks a hermes launched in the embedded terminal pane. Both set by main.cjs. - _truthy = ("1", "true", "yes") - _in_desktop = (os.getenv("HERMES_DESKTOP") or "").strip().lower() in _truthy - _in_desktop_term = (os.getenv("HERMES_DESKTOP_TERMINAL") or "").strip().lower() in _truthy - if _in_desktop or _in_desktop_term: - _desktop_hint = "Runtime surface: you're running inside the Hermes desktop GUI app." - if _in_desktop_term: - _desktop_hint += ( - " You're in its embedded terminal pane, beside the GUI chat — the user can " - "select your output (⌥-drag on macOS, Shift-drag elsewhere) and press " - "⌘/Ctrl+L to send it to the chat composer." - ) - hints.append(_desktop_hint) - if is_wsl(): hints.append(WSL_ENVIRONMENT_HINT) diff --git a/agent/system_prompt.py b/agent/system_prompt.py index b9b26e07abcb..ea33df54a0d0 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -24,6 +24,7 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders. from __future__ import annotations import json +import os from typing import Any, Dict, List, Optional from agent.prompt_builder import ( @@ -44,6 +45,7 @@ from agent.prompt_builder import ( drain_truncation_warnings, ) from agent.runtime_cwd import resolve_context_cwd +from utils import is_truthy_value def _ra(): @@ -110,6 +112,36 @@ def _resolve_platform_hint(agent: Any, platform_key: str, default_hint: str) -> return base +_TUI_EMBEDDED_PANE_CLARIFIER = ( + " You're in its embedded terminal pane, beside the GUI chat — the user can " + "select your output (Option-drag on macOS, Shift-drag elsewhere) and press " + "Cmd/Ctrl+L to send it to the chat composer." +) + + +def _tui_embedded_pane_clarifier(hint: str) -> str: + """Append the desktop-embedded-terminal-pane clarifier to a tui hint. + + Triggered by ``HERMES_DESKTOP_TERMINAL=1`` (set by ``main.cjs`` only on the + shell env of the desktop's embedded TUI PTY — never on the chat backend). + This is a runtime-surface qualifier, not a config override, so it lives at + the resolution site rather than inside ``_resolve_platform_hint`` (which + is purely the config-platform_hints override applier). Byte-stable for the + cache: called once per session build, deterministically from env state. + + Idempotent and empty-safe: re-applying on an already-augmented hint is a + no-op, and an empty input returns empty (we never synthesize the + clarifier without its tui framing). + """ + if not hint: + return hint + if _TUI_EMBEDDED_PANE_CLARIFIER in hint: + return hint + if not is_truthy_value(os.getenv("HERMES_DESKTOP_TERMINAL")): + return hint + return hint + _TUI_EMBEDDED_PANE_CLARIFIER + + def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]: """Assemble the system prompt as three ordered parts. @@ -398,6 +430,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) pass _effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint) + if platform_key == "tui" and _effective_hint: + _effective_hint = _tui_embedded_pane_clarifier(_effective_hint) if _effective_hint: stable_parts.append(_effective_hint) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index 09ccc8459eb1..681002311fdf 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -1084,7 +1084,7 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(ok).toBe(true) // First submit (stale id) → session.resume (stored id) → retry submit (fresh id). expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit']) - expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID }) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' }) }) @@ -1127,7 +1127,7 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt']) expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID }) - expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID }) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID }) }) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 5928bae6fd87..8ebe8889d16c 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -546,7 +546,8 @@ export function usePromptActions({ if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) { try { const resumed = await requestGateway<{ session_id: string }>('session.resume', { - session_id: selectedStoredSessionIdRef.current + session_id: selectedStoredSessionIdRef.current, + source: 'desktop' }) const recoveredId = resumed?.session_id diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 5127b534f1c2..09df1221af84 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -260,7 +260,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) { // Re-register the session in the gateway and get a fresh live ID. const resumed = await requestGateway<{ session_id: string }>('session.resume', { - session_id: selectedStoredSessionIdRef.current + session_id: selectedStoredSessionIdRef.current, + source: 'desktop' }) const recoveredId = resumed?.session_id 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 dc4dcd4176db..e00960de2f2a 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,12 @@ describe('createBackendSessionForSend profile routing', () => { expect(params).toMatchObject({ profile: 'default' }) }) + it('tags new desktop chats as desktop sessions', async () => { + const params = await createWith(() => {}) + + expect(params).toMatchObject({ source: 'desktop' }) + }) + it('passes the current workspace cwd into session.create', async () => { const params = await createWith(() => { $currentCwd.set('/remote/worktree') @@ -328,6 +334,7 @@ describe('resumeSession failure recovery', () => { expect(resumeParams).not.toHaveProperty('lazy') expect(resumeParams).not.toHaveProperty('eager_build') + expect(resumeParams).toMatchObject({ source: 'desktop' }) }) it('arms the failure latch when resume succeeds with an empty transcript for a non-empty stored session', async () => { @@ -412,6 +419,78 @@ describe('resumeSession failure recovery', () => { }) }) +function BranchHarness({ + onReady, + requestGateway +}: { + onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise) => void + requestGateway: (method: string, params?: Record) => Promise +}) { + const ref = (value: T): MutableRefObject => ({ current: value }) + + const actions = useSessionActions({ + activeSessionId: null, + activeSessionIdRef: ref(null), + busyRef: ref(false), + creatingSessionRef: ref(false), + ensureSessionState: () => ({}) as ClientSessionState, + getRouteToken: () => 'token', + navigate: vi.fn() as never, + requestGateway, + runtimeIdByStoredSessionIdRef: ref(new Map()), + selectedStoredSessionId: null, + selectedStoredSessionIdRef: ref(null), + sessionStateByRuntimeIdRef: ref(new Map()), + syncSessionStateToView: vi.fn(), + updateSessionState: () => ({}) as ClientSessionState + }) + + useEffect(() => { + onReady(actions.branchStoredSession) + }, [actions.branchStoredSession, onReady]) + + return null +} + +describe('branchStoredSession desktop source tagging', () => { + afterEach(() => { + cleanup() + setSessions([]) + vi.restoreAllMocks() + }) + + it('tags desktop branch sessions as desktop sessions', async () => { + let createParams: Record | undefined + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.create') { + createParams = params + + return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never + } + + return {} as never + }) + + setSessions([storedSession({ id: 'stored-parent', message_count: 1 })]) + vi.mocked(getSessionMessages).mockResolvedValue({ + messages: [{ content: 'branch me', role: 'user', timestamp: 1 }], + session_id: 'stored-parent' + } as never) + + let branchStoredSession: ((storedSessionId: string) => Promise) | null = null + render( (branchStoredSession = branch)} requestGateway={requestGateway} />) + await waitFor(() => expect(branchStoredSession).not.toBeNull()) + + await expect(branchStoredSession!('stored-parent')).resolves.toBe(true) + + expect(createParams).toMatchObject({ + parent_session_id: 'stored-parent', + source: 'desktop' + }) + }) +}) + // ── Warm-cache mapping integrity (the "open chat A, chat B loads" bug) ───────── // resumeSession's warm fast-path maps storedSessionId -> runtimeId -> cached // state. A reaped/respawned pooled backend re-mints runtime ids, so a recycled diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 0f88fc948100..2b51f002e4b8 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -176,6 +176,7 @@ export function useSessionActions({ const created = await requestGateway('session.create', { cols: 96, + source: 'desktop', ...(cwd && { cwd }), ...(newChatProfile ? { profile: newChatProfile } : {}), ...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}), @@ -460,6 +461,7 @@ export function useSessionActions({ const resumePromise = requestGateway('session.resume', { session_id: storedSessionId, cols: 96, + source: 'desktop', // Watch windows attach lazily (live mirror). Every other cold resume // gets the gateway's default deferred build: the RPC returns the // transcript immediately instead of blocking the switch on _make_agent @@ -642,6 +644,7 @@ export function useSessionActions({ // No title: the backend auto-names the branch from its parent's lineage. const branched = await requestGateway('session.create', { cols: 96, + source: 'desktop', ...(cwd && { cwd }), messages: branchMessages.map(({ content, role }) => ({ content, role })), ...(parentStoredId && { parent_session_id: parentStoredId }) diff --git a/tests/agent/test_platform_hint_desktop.py b/tests/agent/test_platform_hint_desktop.py new file mode 100644 index 000000000000..92f90c3affaa --- /dev/null +++ b/tests/agent/test_platform_hint_desktop.py @@ -0,0 +1,246 @@ +"""System-prompt assembly for the desktop chat surface. + +Pins the second half of the fix: the new ``PLATFORM_HINTS["desktop"]`` +entry, the deletion of the standalone desktop-hint block from +``build_environment_hints()``, and the lookup-site extension that +appends the embedded-terminal-pane clarifier to the ``tui`` platform hint +when ``HERMES_DESKTOP_TERMINAL=1``. + +These tests run against the real prompt builders (no mocks) because +cache-stability and byte-for-byte text contracts are what we are +verifying — mocking the resolver would hide exactly the class of bug +this test covers. +""" + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from agent.prompt_builder import PLATFORM_HINTS, build_environment_hints +from agent.system_prompt import ( + _tui_embedded_pane_clarifier, + build_system_prompt_parts, +) + + +def _stable_prompt(agent): + with ( + patch("run_agent.load_soul_md", return_value=""), + patch("run_agent.build_nous_subscription_prompt", return_value=""), + patch("run_agent.build_environment_hints", return_value=""), + patch("run_agent.build_context_files_prompt", return_value=""), + ): + return build_system_prompt_parts(agent)["stable"] + + +def _make_agent(platform="", **overrides): + base = dict( + load_soul_identity=False, + skip_context_files=False, + valid_tool_names=[], + _task_completion_guidance=False, + _tool_use_enforcement=False, + _environment_probe=False, + _kanban_worker_guidance="", + _memory_store=None, + _memory_manager=None, + _platform_hint_overrides={}, + model="", + provider="", + pass_session_id=False, + session_id="", + ) + base["platform"] = platform + base.update(overrides) + return SimpleNamespace(**base) + + +class TestDesktopHintEntry: + def test_desktop_key_exists(self): + """The map must carry a "desktop" entry — without it the platform + hint lookup falls through to an empty string and the agent gets no + surface framing at all on the desktop chat surface.""" + assert "desktop" in PLATFORM_HINTS + + def test_desktop_hint_disambiguates_from_terminal(self): + """The agent must be told it is in a graphical chat surface, NOT a + terminal. This is the line that kills the contradiction with the + old tui mis-tag.""" + hint = PLATFORM_HINTS["desktop"] + lowered = hint.lower() + assert "desktop" in lowered + assert "not a terminal" in lowered + assert "graphical chat surface" in lowered + + def test_desktop_hint_advertises_markdown(self): + """The desktop renderer supports full GFM (verified via the + Streamdown pipeline in apps/desktop). The hint must steer the + agent toward markdown, not away from it like the cli/tui hints do.""" + hint = PLATFORM_HINTS["desktop"] + assert "markdown" in hint.lower() + + def test_desktop_hint_advertises_media_delivery(self): + """The desktop chat intercepts MEDIA:/abs/path like telegram — images + inline, audio/video inline players, other files as download links. + Without this line the agent falls back to the cli/tui "state the + path in text" model, which is the wrong UX for the desktop surface.""" + hint = PLATFORM_HINTS["desktop"] + assert "MEDIA:" in hint + + def test_desktop_hint_advertises_inline_image_urls(self): + hint = PLATFORM_HINTS["desktop"] + assert "![alt](url)" in hint + + def test_desktop_hint_does_not_inherit_tui_cron_local_only_block(self): + """The desktop chat surface's cron delivery semantics differ from + the standalone TUI — desktop runs its own cron ticker in-process + (hermes_cli/web_server.py under HERMES_DESKTOP=1). We deliberately + do NOT parrot the tui "LOCAL-ONLY … no live-delivery channel" block + into the desktop hint, since partially-correct cron guidance is + exactly the bug class we are fixing. Cron guidance for desktop is + deferred to a follow-up issue.""" + hint = PLATFORM_HINTS["desktop"] + assert "LOCAL-ONLY" not in hint + + +class TestDesktopHintBlockRemoved: + """The standalone desktop-hint block that used to live in + ``build_environment_hints()`` (lines ~1130-1144) was a band-aid for the + missing ``PLATFORM_HINTS["desktop"]`` entry. Once that entry exists, the + block is dead code that competes with the platform hint's claim of + what surface the agent is on. It must be gone.""" + + def test_build_environment_hints_has_no_runtime_surface_line(self, monkeypatch): + monkeypatch.setenv("HERMES_DESKTOP", "1") + monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False) + from agent.prompt_builder import _clear_backend_probe_cache + _clear_backend_probe_cache() + hints = build_environment_hints() + assert "Runtime surface:" not in hints + assert "desktop GUI app" not in hints + + def test_build_environment_hints_has_no_embedded_pane_clarifier(self, monkeypatch): + """The ⌥-drag / ⌘+L embedded-pane clarifier moves to the platform-hint + resolution site (system_prompt.py), not build_environment_hints().""" + monkeypatch.setenv("HERMES_DESKTOP", "1") + monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1") + from agent.prompt_builder import _clear_backend_probe_cache + _clear_backend_probe_cache() + hints = build_environment_hints() + assert "embedded terminal pane" not in hints + assert "Shift-drag" not in hints + + +class TestPlatformHintResolutionInStablePrompt: + """End-to-end through ``build_system_prompt_parts`` — the platform tag on + the agent drives BOTH which PLATFORM_HINTS entry gets appended AND + whether the embedded-pane clarifier follows it. The desktop-hint block + that used to live in ``build_environment_hints()`` is gone.""" + + def test_desktop_platform_yields_desktop_hint_no_tui_framing(self, monkeypatch): + monkeypatch.setenv("HERMES_DESKTOP", "1") + monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False) + stable = _stable_prompt(_make_agent(platform="desktop")) + assert PLATFORM_HINTS["desktop"] in stable + assert "terminal UI" not in stable + assert "Runtime surface:" not in stable + assert "embedded terminal pane" not in stable + + def test_standalone_tui_yields_plain_tui_hint_no_clarifier(self, monkeypatch): + monkeypatch.delenv("HERMES_DESKTOP", raising=False) + monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False) + stable = _stable_prompt(_make_agent(platform="tui")) + assert PLATFORM_HINTS["tui"] in stable + assert "embedded terminal pane" not in stable + + def test_embedded_tui_yields_tui_hint_with_clarifier(self, monkeypatch): + monkeypatch.setenv("HERMES_DESKTOP", "1") + monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1") + stable = _stable_prompt(_make_agent(platform="tui")) + assert PLATFORM_HINTS["tui"] in stable + assert "embedded terminal pane" in stable + assert "Shift-drag" in stable or "Option-drag" in stable or "⌥" in stable + + def test_embedded_clarifier_does_not_attach_to_desktop_platform(self, monkeypatch): + """Critical regression: even when HERMES_DESKTOP_TERMINAL=1, a + desktop-tagged session must NOT get the embedded-pane clarifier — + the clarifier describes the *embedded terminal pane*, which a + desktop chat session is not.""" + monkeypatch.setenv("HERMES_DESKTOP", "1") + monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1") + stable = _stable_prompt(_make_agent(platform="desktop")) + assert "embedded terminal pane" not in stable + + +class TestEmbeddedTuiPaneClarifier: + """When ``HERMES_DESKTOP_TERMINAL=1``, a standalone ``hermes --tui`` is + running inside the desktop's embedded terminal pane. The user can + ⌥-drag-select its output and ⌘/Ctrl+L to send it to the chat composer. + That clarifier must be appended to the ``tui`` platform hint at the + resolution site, NOT baked into the static ``PLATFORM_HINTS["tui"]`` + string (which is shared with every standalone TUI session and must + stay byte-stable).""" + + def test_tui_standalone_hint_byte_stable_without_env(self, monkeypatch): + """Without HERMES_DESKTOP_TERMINAL, the clarifier is a no-op and the + resolved tui hint is exactly the static PLATFORM_HINTS["tui"] + string. Cache-stable for every standalone TUI session.""" + monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False) + out = _tui_embedded_pane_clarifier(PLATFORM_HINTS["tui"]) + assert out == PLATFORM_HINTS["tui"] + + def test_embedded_pane_clarifier_appended_when_env_set(self, monkeypatch): + monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1") + out = _tui_embedded_pane_clarifier(PLATFORM_HINTS["tui"]) + assert out.startswith(PLATFORM_HINTS["tui"]) + assert "embedded terminal pane" in out + assert "Shift-drag" in out or "Option-drag" in out or "⌥" in out + + def test_embedded_pane_clarifier_idempotent(self, monkeypatch): + """Calling the clarifier twice must NOT double-append the sentence. + Cache-stability: the resolver is called once per session build, so + re-applying on an already-augmented hint is a no-op.""" + monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1") + once = _tui_embedded_pane_clarifier(PLATFORM_HINTS["tui"]) + twice = _tui_embedded_pane_clarifier(once) + assert once == twice + + def test_embedded_pane_clarifier_does_not_touch_empty_hint(self, monkeypatch): + """Defensive: if the tui hint is somehow empty (e.g. overridden to + empty by config), do not synthesize a clarifier-only hint — that + would put a desktop-pane reference in the prompt without the tui + surface framing it sits under.""" + monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1") + out = _tui_embedded_pane_clarifier("") + assert out == "" + + @pytest.mark.parametrize("val", ["0", "false", "no", "", "0", "False"]) + def test_falsy_env_does_not_trigger_clarifier(self, monkeypatch, val): + monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", val) + out = _tui_embedded_pane_clarifier(PLATFORM_HINTS["tui"]) + assert out == PLATFORM_HINTS["tui"], ( + f"HERMES_DESKTOP_TERMINAL={val!r} should not trigger clarifier" + ) + + +class TestContradictionGone: + """The original contradiction: a single assembled system prompt + contained both ``You are running in the Hermes terminal UI (TUI).`` and + ``Runtime surface: you're running inside the Hermes desktop GUI app.``. + After the fix, no single session's prompt can carry both.""" + + def test_desktop_chat_session_has_no_tui_framing(self, monkeypatch): + monkeypatch.setenv("HERMES_DESKTOP", "1") + monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False) + assert "tui" in PLATFORM_HINTS + assert "desktop" in PLATFORM_HINTS + desktop_hint = PLATFORM_HINTS["desktop"] + tui_hint = PLATFORM_HINTS["tui"] + assert "terminal UI" not in desktop_hint + assert "terminal UI" in tui_hint + + def test_tui_hint_does_not_carry_desktop_marker(self): + tui_hint = PLATFORM_HINTS["tui"] + assert "desktop GUI app" not in tui_hint + assert "Runtime surface:" not in tui_hint diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 6f58d35d82c9..50a9fa8d5ce6 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -13,6 +13,7 @@ import pytest from hermes_constants import reset_hermes_home_override, set_hermes_home_override from hermes_cli.active_sessions import active_session_registry_snapshot +from hermes_cli.browser_connect import ChromeDebugLaunch from tui_gateway import server @@ -1827,7 +1828,7 @@ def test_session_close_commits_memory_and_fires_finalize_hook(monkeypatch): monkeypatch.setattr( server, "_notify_session_boundary", - lambda event, session_id: calls["hooks"].append((event, session_id)), + lambda event, session_id, *_args: calls["hooks"].append((event, session_id)), ) try: @@ -1959,7 +1960,7 @@ def test_init_session_fires_reset_hook(monkeypatch): monkeypatch.setattr( server, "_notify_session_boundary", - lambda event, session_id: hooks.append((event, session_id)), + lambda event, session_id, *_args: hooks.append((event, session_id)), ) import tools.approval as _approval @@ -5498,7 +5499,7 @@ def test_session_create_close_race_does_not_orphan_worker(monkeypatch): release_build = threading.Event() build_entered = threading.Event() - def _slow_make_agent(sid, key, session_id=None, session_db=None): + def _slow_make_agent(sid, key, session_id=None, session_db=None, **_kwargs): build_started.set() build_entered.set() release_build.wait(timeout=3.0) @@ -5606,7 +5607,7 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch): self.base_url = "" self.api_key = "" - monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_db=None: _FakeAgent()) + monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_db=None, **_kwargs: _FakeAgent()) monkeypatch.setattr(server, "_SlashWorker", _FakeWorker) monkeypatch.setattr( server, @@ -5712,7 +5713,7 @@ def test_session_create_continues_when_state_db_is_unavailable(monkeypatch): emits = [] - monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_db=None: _FakeAgent()) + monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_db=None, **_kwargs: _FakeAgent()) monkeypatch.setattr(server, "_SlashWorker", _FakeWorker) monkeypatch.setattr(server, "_get_db", lambda: None) monkeypatch.setattr(server, "_session_info", lambda _a, *a2: {"model": "x"}) @@ -6810,8 +6811,10 @@ def test_browser_manage_connect_default_local_reports_launch_hint(monkeypatch): _stub_urlopen(monkeypatch, ok=False) with ( patch( - "hermes_cli.browser_connect.try_launch_chrome_debug", return_value=False + "hermes_cli.browser_connect.launch_chrome_debug", + return_value=ChromeDebugLaunch(), ), + patch("hermes_cli.browser_connect.manual_chrome_debug_command", return_value=None), patch( "hermes_cli.browser_connect.get_chrome_debug_candidates", return_value=[], @@ -6866,8 +6869,10 @@ def test_browser_manage_connect_no_session_skips_progress_events(monkeypatch): _stub_urlopen(monkeypatch, ok=False) with ( patch( - "hermes_cli.browser_connect.try_launch_chrome_debug", return_value=False + "hermes_cli.browser_connect.launch_chrome_debug", + return_value=ChromeDebugLaunch(), ), + patch("hermes_cli.browser_connect.manual_chrome_debug_command", return_value=None), patch( "hermes_cli.browser_connect.get_chrome_debug_candidates", return_value=[], diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 170e78aec643..5aa3daa00ec5 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -315,7 +315,7 @@ def test_session_resume_returns_hydrated_messages(server, monkeypatch): ] monkeypatch.setattr(server, "_get_db", lambda: _DB()) - monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None, session_db=None: object()) + monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None, session_db=None, **_kwargs: object()) monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80, **_kwargs: None) monkeypatch.setattr(server, "_session_info", lambda _agent, _session=None: {"model": "test/model"}) @@ -509,7 +509,7 @@ def test_session_resume_handles_multimodal_list_content(server, monkeypatch): return [multimodal_user, text_only_assistant] monkeypatch.setattr(server, "_get_db", lambda: _DB()) - monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None, session_db=None: object()) + monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None, session_db=None, **_kwargs: object()) monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80, **_kwargs: None) monkeypatch.setattr(server, "_session_info", lambda _agent, _session=None: {"model": "test/model"}) @@ -795,7 +795,7 @@ def test_session_resume_reuses_existing_live_session(server, monkeypatch): def close(self): closed_sids.append(self.sid) - def make_agent(sid, key, session_id=None, session_db=None): + def make_agent(sid, key, session_id=None, session_db=None, **_kwargs): created_sids.append(sid) first_agent_started.set() assert agent_can_finish.wait(timeout=1) @@ -1006,7 +1006,7 @@ def test_session_resume_live_payload_uses_current_history_with_ancestors(server, monkeypatch.setattr( server, "_make_agent", - lambda _sid, key, session_id=None, session_db=None: types.SimpleNamespace( + lambda _sid, key, session_id=None, session_db=None, **_kwargs: types.SimpleNamespace( model="test/model", session_id=session_id or key ), ) @@ -1144,7 +1144,7 @@ def test_session_branch_persists_branched_from_marker(server, monkeypatch): monkeypatch.setattr( server, "_make_agent", - lambda _sid, key, session_id=None, session_db=None: types.SimpleNamespace( + lambda _sid, key, session_id=None, session_db=None, **_kwargs: types.SimpleNamespace( model="test/model", session_id=session_id or key ), ) diff --git a/tests/tui_gateway/test_session_platform_resolution.py b/tests/tui_gateway/test_session_platform_resolution.py new file mode 100644 index 000000000000..da241530604e --- /dev/null +++ b/tests/tui_gateway/test_session_platform_resolution.py @@ -0,0 +1,147 @@ +"""Platform/source tagging for the desktop chat surface. + +The desktop app's chat panel uses ``hermes serve`` (the ``tui_gateway`` +backend), so every chat session historically got ``platform="tui"`` stamped +on it — even though the user is in a graphical chat surface, not a +terminal. That mis-tag is why the agent suggested TUI-only slash commands +(like ``/reload-mcp``) to desktop chat users. + +These tests pin the env-var matrix that resolves the session platform at +``tui_gateway`` session-creation time: + + HERMES_DESKTOP=1, HERMES_DESKTOP_TERMINAL unset -> platform="desktop" + HERMES_DESKTOP=1, HERMES_DESKTOP_TERMINAL=1 -> platform="tui" (embedded pane) + neither set -> platform="tui" (standalone) + +The resolver helper is import-safe (no heavy module side effects) so it +can be unit-tested without spinning up the full gateway. +""" + +import importlib + +import pytest + + +def _reload_resolver(): + import tui_gateway.server as _srv + importlib.reload(_srv) + return _srv + + +@pytest.fixture +def clean_env(monkeypatch): + monkeypatch.delenv("HERMES_DESKTOP", raising=False) + monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False) + return monkeypatch + + +class TestResolveSessionPlatform: + def test_standalone_tui_neither_env_set(self, clean_env): + _srv = _reload_resolver() + assert _srv._resolve_session_platform() == "tui" + + def test_desktop_chat_backend_gets_desktop_tag(self, clean_env): + clean_env.setenv("HERMES_DESKTOP", "1") + _srv = _reload_resolver() + assert _srv._resolve_session_platform() == "desktop" + + def test_desktop_embedded_terminal_pane_stays_tui(self, clean_env): + clean_env.setenv("HERMES_DESKTOP", "1") + clean_env.setenv("HERMES_DESKTOP_TERMINAL", "1") + _srv = _reload_resolver() + assert _srv._resolve_session_platform() == "tui" + + def test_desktop_terminal_alone_means_standalone_tui(self, clean_env): + clean_env.setenv("HERMES_DESKTOP_TERMINAL", "1") + _srv = _reload_resolver() + assert _srv._resolve_session_platform() == "tui" + + @pytest.mark.parametrize("val", ["1", "true", "yes", "on", "TRUE", "Yes", "ON"]) + def test_truthy_variants_recognized(self, clean_env, val): + clean_env.setenv("HERMES_DESKTOP", val) + _srv = _reload_resolver() + assert _srv._resolve_session_platform() == "desktop" + + @pytest.mark.parametrize("val", ["0", "false", "", "no", "off", "False"]) + def test_falsy_variants_fall_back_to_tui(self, clean_env, val): + clean_env.setenv("HERMES_DESKTOP", val) + _srv = _reload_resolver() + assert _srv._resolve_session_platform() == "tui" + + def test_embedded_terminal_overrides_desktop_when_both_set(self, clean_env): + """The terminal-pane qualifier must short-circuit the desktop-backend + marker. An embedded TUI is a TUI, not a desktop chat surface.""" + clean_env.setenv("HERMES_DESKTOP", "1") + clean_env.setenv("HERMES_DESKTOP_TERMINAL", "true") + _srv = _reload_resolver() + assert _srv._resolve_session_platform() == "tui" + + +class TestResolveSessionSource: + def test_explicit_source_param_wins(self, clean_env): + _srv = _reload_resolver() + assert _srv._resolve_session_source("telegram") == "telegram" + + def test_explicit_empty_source_falls_back_to_env(self, clean_env): + clean_env.setenv("HERMES_DESKTOP", "1") + _srv = _reload_resolver() + assert _srv._resolve_session_source("") == "desktop" + + def test_explicit_none_source_falls_back_to_env(self, clean_env): + clean_env.setenv("HERMES_DESKTOP", "1") + _srv = _reload_resolver() + assert _srv._resolve_session_source(None) == "desktop" + + def test_no_env_no_param_defaults_to_tui(self, clean_env): + _srv = _reload_resolver() + assert _srv._resolve_session_source(None) == "tui" + + def test_embedded_terminal_default_is_tui(self, clean_env): + clean_env.setenv("HERMES_DESKTOP", "1") + clean_env.setenv("HERMES_DESKTOP_TERMINAL", "1") + _srv = _reload_resolver() + assert _srv._resolve_session_source(None) == "tui" + + def test_explicit_source_param_resists_env_drift(self, clean_env): + """A caller that explicitly passes source="cli" must not be silently + rewritten to "desktop" by env vars — the resolver only fills in the + default when one is missing.""" + clean_env.setenv("HERMES_DESKTOP", "1") + _srv = _reload_resolver() + assert _srv._resolve_session_source("cli") == "cli" + + +class TestResolveAgentPlatform: + def test_explicit_desktop_source_drives_agent_platform_without_env(self, clean_env): + _srv = _reload_resolver() + assert _srv._resolve_agent_platform("desktop") == "desktop" + + def test_missing_source_falls_back_to_env_resolved_platform(self, clean_env): + clean_env.setenv("HERMES_DESKTOP", "1") + _srv = _reload_resolver() + assert _srv._resolve_agent_platform(None) == "desktop" + + def test_explicit_tui_source_keeps_embedded_terminal_as_tui(self, clean_env): + clean_env.setenv("HERMES_DESKTOP", "1") + _srv = _reload_resolver() + assert _srv._resolve_agent_platform("tui") == "tui" + + +class TestSessionSourceFallback: + def test_session_source_uses_existing_session_value(self, clean_env): + clean_env.setenv("HERMES_DESKTOP", "1") + _srv = _reload_resolver() + assert _srv._session_source({"source": "telegram"}) == "telegram" + + def test_session_source_defaults_to_desktop_under_desktop_backend(self, clean_env): + clean_env.setenv("HERMES_DESKTOP", "1") + _srv = _reload_resolver() + assert _srv._session_source({}) == "desktop" + assert _srv._session_source(None) == "desktop" + + def test_session_source_defaults_to_tui_for_embedded_terminal(self, clean_env): + clean_env.setenv("HERMES_DESKTOP", "1") + clean_env.setenv("HERMES_DESKTOP_TERMINAL", "1") + _srv = _reload_resolver() + assert _srv._session_source({}) == "tui" + assert _srv._session_source(None) == "tui" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index fb98f615fa30..8092147bc79c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -404,12 +404,18 @@ def _load_busy_input_mode() -> str: return raw if raw in {"queue", "steer", "interrupt"} else "interrupt" -def _notify_session_boundary(event_type: str, session_id: str | None) -> None: +def _notify_session_boundary( + event_type: str, session_id: str | None, platform: str | None = None +) -> None: """Fire session lifecycle hooks with CLI parity.""" try: from hermes_cli.plugins import invoke_hook as _invoke_hook - _invoke_hook(event_type, session_id=session_id, platform="tui") + _invoke_hook( + event_type, + session_id=session_id, + platform=_resolve_agent_platform(platform), + ) except Exception: pass @@ -477,6 +483,7 @@ def _transfer_active_session_slot( new_lease, limit_message = _claim_active_session_slot( new_session_id, live_session_id=sid, + surface=_session_source(session), ) if new_lease is not None: old_lease = session.pop("active_session_lease", None) @@ -612,7 +619,7 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No session_key = session.get("session_key") session_id = getattr(agent, "session_id", None) or session_key - _notify_session_boundary("on_session_finalize", session_id) + _notify_session_boundary("on_session_finalize", session_id, _session_source(session)) # Mark session ended in DB so it doesn't linger as a ghost row in /resume. # Use session_id (from agent.session_id) not session_key — after compression, @@ -1322,6 +1329,7 @@ def _start_agent_build(sid: str, session: dict) -> None: kw = {"session_db": session_db} if resume_sid := current.get("resume_session_id"): kw["session_id"] = resume_sid + kw["platform_override"] = _session_source(current) resume_overrides = current.get("resume_runtime_overrides") if isinstance(resume_overrides, dict) and resume_overrides: # Cold deferred resume: restore the full persisted runtime @@ -1401,7 +1409,7 @@ def _start_agent_build(sid: str, session: dict) -> None: with _sessions_lock: if sid in _sessions: _sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid]) - _notify_session_boundary("on_session_reset", key) + _notify_session_boundary("on_session_reset", key, _session_source(current)) info = _session_info(agent, current) cfg_warn = _probe_config_health(_load_cfg()) @@ -1603,7 +1611,7 @@ def _session_source(session: dict | None) -> str: source = str(session.get("source") or "").strip() if source: return source - return "tui" + return _resolve_session_platform() def _register_session_cwd(session: dict | None) -> None: @@ -1947,7 +1955,7 @@ def _set_session_context(session_key: str, cwd: str | None = None) -> list: # know the parent workspace pass it explicitly so spawned agents inherit # it instead of falling back to the gateway launch dir. resolved = cwd if cwd is not None else _cwd_for_session_key(session_key) - source = "tui" + source = _resolve_session_platform() with _sessions_lock: for sess in list(_sessions.values()): if sess.get("session_key") == session_key: @@ -2050,6 +2058,49 @@ def _resolve_model() -> str: return "anthropic/claude-sonnet-4" +def _resolve_session_platform() -> str: + """Resolve the platform tag for a tui_gateway-routed session. + + The desktop app's chat panel and the standalone TUI both speak to this + gateway; without a branch they all get stamped ``platform="tui"``, + which makes the agent think it's talking to a terminal user. That + mis-tag is the root cause of the desktop chat agent suggesting + TUI-only slash commands (``/reload-mcp``, …) to chat-panel users. + + Resolution: + * ``HERMES_DESKTOP=1`` and ``HERMES_DESKTOP_TERMINAL`` unset → "desktop" + (the chat-panel backend — a graphical React surface, not a terminal). + * ``HERMES_DESKTOP_TERMINAL=1`` → "tui" + (``hermes --tui`` running in the desktop's embedded terminal pane; + it IS a TUI, just embedded. The clarifier attached to the tui hint + in system_prompt.py tells the agent about the embedding.) + * neither set → "tui" + (standalone ``hermes --tui``.) + """ + if is_truthy_value(os.environ.get("HERMES_DESKTOP")) and not is_truthy_value( + os.environ.get("HERMES_DESKTOP_TERMINAL") + ): + return "desktop" + return "tui" + + +def _resolve_session_source(explicit: str | None) -> str: + """Default the session DB ``source`` field from the resolved platform. + + A caller that explicitly passes ``source`` (e.g. a plugin session tagged + ``"telegram"``) keeps its value. Only an empty/None ``source`` falls back + to the env-resolved platform — so env-driven resolution never silently + rewrites a caller's intent. + """ + if explicit: + return explicit + return _resolve_session_platform() + + +def _resolve_agent_platform(source: str | None) -> str: + return _resolve_session_source(source) + + def _config_model_target() -> tuple[str, str]: """(model, provider) currently selected by config.yaml — and ONLY config. @@ -2528,7 +2579,7 @@ def _load_enabled_toolsets() -> list[str] | None: try: from agent.coding_context import coding_selection - selection = coding_selection(platform="tui") + selection = coding_selection(platform=_resolve_session_platform()) if selection is not None: # Fold in `project` here too: this is a GUI-only resolver, and # the focus-mode coding posture returns before the fallback path @@ -4222,6 +4273,7 @@ def _reset_session_agent(sid: str, session: dict) -> dict: sid, session["session_key"], session_id=session["session_key"], + platform_override=_session_source(session), **reset_kw, ) finally: @@ -4371,6 +4423,7 @@ def _make_agent( provider_override: str | None = None, reasoning_config_override: dict | None = None, service_tier_override: str | None = None, + platform_override: str | None = None, ): from run_agent import AIAgent @@ -4513,7 +4566,7 @@ def _make_agent( provider_sort=_pr.get("sort"), provider_require_parameters=_pr.get("require_parameters", False), provider_data_collection=_pr.get("data_collection"), - platform="tui", + platform=_resolve_agent_platform(platform_override), session_id=session_id or key, session_db=session_db if session_db is not None else _get_db(), ephemeral_system_prompt=system_prompt or None, @@ -4534,6 +4587,7 @@ def _init_session( cols: int = 80, cwd: str | None = None, session_db=None, + source: str | None = None, ): now = time.time() with _sessions_lock: @@ -4553,6 +4607,7 @@ def _init_session( "cols": cols, "slash_worker": None, "show_reasoning": _load_show_reasoning(), + "source": _resolve_session_source(source), "tool_progress_mode": _load_tool_progress_mode(), "edit_snapshots": {}, "tool_started_at": {}, @@ -4621,7 +4676,7 @@ def _init_session( with _sessions_lock: if sid in _sessions: _sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid]) - _notify_session_boundary("on_session_reset", key) + _notify_session_boundary("on_session_reset", key, _session_source(_sessions.get(sid, {}))) _emit("session.info", sid, _session_info(agent, _sessions.get(sid, {}))) _schedule_mcp_late_refresh(sid, agent) @@ -5067,7 +5122,7 @@ def _(rid, params: dict) -> dict: except Exception: explicit_cwd = False resolved_cwd = _completion_cwd(params) - source = str(params.get("source") or "tui").strip() or "tui" + source = _resolve_session_source(str(params.get("source") or "").strip() or None) _enable_gateway_prompts() # ``profile`` (app-global remote mode): a new chat started under a non-launch @@ -5102,7 +5157,9 @@ def _(rid, params: dict) -> dict: ready = threading.Event() now = time.time() - lease, limit_message = _claim_active_session_slot(key, live_session_id=sid) + lease, limit_message = _claim_active_session_slot( + key, live_session_id=sid, surface=source + ) if limit_message is not None: return _err(rid, 4090, limit_message) @@ -5528,7 +5585,10 @@ def _(rid, params: dict) -> dict: # (resume_session_id keeps the upgrade on the stored conversation). if is_truthy_value(params.get("lazy", False)): sid = uuid.uuid4().hex[:8] - lease, limit_message = _claim_active_session_slot(target, live_session_id=sid) + source = _resolve_session_source(str(params.get("source") or "").strip() or None) + lease, limit_message = _claim_active_session_slot( + target, live_session_id=sid, surface=source + ) if limit_message is not None: return _err(rid, 4090, limit_message) try: @@ -5547,7 +5607,7 @@ def _(rid, params: dict) -> dict: cwd=cwd, history=history, lease=lease, - source=str(params.get("source") or "tui").strip() or "tui", + source=source, close_on_disconnect=is_truthy_value(params.get("close_on_disconnect", False)), profile_home=profile_home, lazy=True, @@ -5589,7 +5649,10 @@ def _(rid, params: dict) -> dict: # session's persisted runtime identity, and is a real (upgradable) session. if not is_truthy_value(params.get("eager_build", False)): sid = uuid.uuid4().hex[:8] - lease, limit_message = _claim_active_session_slot(target, live_session_id=sid) + source = _resolve_session_source(str(params.get("source") or "").strip() or None) + lease, limit_message = _claim_active_session_slot( + target, live_session_id=sid, surface=source + ) if limit_message is not None: return _err(rid, 4090, limit_message) # Interactive resume routes approvals/clarify through gateway prompts; @@ -5620,7 +5683,7 @@ def _(rid, params: dict) -> dict: cwd=cwd, history=history, lease=lease, - source=str(params.get("source") or "tui").strip() or "tui", + source=source, close_on_disconnect=is_truthy_value(params.get("close_on_disconnect", False)), display_history_prefix=prefix, profile_home=profile_home, @@ -5659,7 +5722,10 @@ def _(rid, params: dict) -> dict: # _session_resume_lock across it would stall session.close on the main # dispatch thread (it's not a _LONG_HANDLER), blocking fast-path RPCs. sid = uuid.uuid4().hex[:8] - lease, limit_message = _claim_active_session_slot(target, live_session_id=sid) + source = _resolve_session_source(str(params.get("source") or "").strip() or None) + lease, limit_message = _claim_active_session_slot( + target, live_session_id=sid, surface=source + ) if limit_message is not None: return _err(rid, 4090, limit_message) _enable_gateway_prompts() @@ -5697,6 +5763,7 @@ def _(rid, params: dict) -> dict: target, session_id=target, session_db=db, + platform_override=source, **stored_runtime_overrides, ) finally: @@ -5747,6 +5814,7 @@ def _(rid, params: dict) -> dict: cols=cols, cwd=profile_resume_cwd, session_db=db, + source=source, ) finally: if init_home_token is not None: @@ -7917,7 +7985,10 @@ def _(rid, params: dict) -> dict: return _err(rid, 4008, "nothing to branch — send a message first") new_key = _new_session_key() new_sid = uuid.uuid4().hex[:8] - lease, limit_message = _claim_active_session_slot(new_key, live_session_id=new_sid) + source = _session_source(session) + lease, limit_message = _claim_active_session_slot( + new_key, live_session_id=new_sid, surface=source + ) if limit_message is not None: return _err(rid, 4090, limit_message) branch_name = params.get("name", "") @@ -7933,7 +8004,7 @@ def _(rid, params: dict) -> dict: ) db.create_session( new_key, - source=_session_source(session), + source=source, model=_resolve_model(), # Stable _branched_from marker so list_sessions_rich() keeps the # branch visible in /resume and /sessions. The TUI branch leaves @@ -7958,11 +8029,21 @@ def _(rid, params: dict) -> dict: try: tokens = _set_session_context(new_key) try: - agent = _make_agent(new_sid, new_key, session_id=new_key) + agent = _make_agent( + new_sid, + new_key, + session_id=new_key, + platform_override=source, + ) finally: _clear_session_context(tokens) _init_session( - new_sid, new_key, agent, list(history), cols=session.get("cols", 80) + new_sid, + new_key, + agent, + list(history), + cols=session.get("cols", 80), + source=source, ) if new_sid in _sessions: _sessions[new_sid]["active_session_lease"] = lease From aab351bfa6c66381bda6c3a7b61dcd4e65f5c5e5 Mon Sep 17 00:00:00 2001 From: Dan Schnurbusch Date: Mon, 6 Jul 2026 12:09:57 -0500 Subject: [PATCH 214/610] fix(delegation): route async results to origin session Carry the live TUI session id with async delegation completion events and prefer the commissioning UI session when desktop pollers share the completion queue. Resolve compressed session keys to their continuation before treating events as orphaned, and capture the live parent agent session id for TUI/ACP dispatch. --- gateway/session_context.py | 11 +++ tests/test_tui_gateway_server.py | 81 +++++++++++++++++++-- tests/tools/test_async_delegation.py | 62 ++++++++++++++++ tools/async_delegation.py | 6 ++ tools/delegate_tool.py | 20 ++++++ tui_gateway/server.py | 102 +++++++++++++++++++++++---- 6 files changed, 262 insertions(+), 20 deletions(-) diff --git a/gateway/session_context.py b/gateway/session_context.py index cdd1a8bfafef..2214c2a56883 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -79,6 +79,13 @@ _SESSION_USER_ID: ContextVar = ContextVar("HERMES_SESSION_USER_ID", default=_UNS _SESSION_USER_NAME: ContextVar = ContextVar("HERMES_SESSION_USER_NAME", default=_UNSET) _SESSION_KEY: ContextVar = ContextVar("HERMES_SESSION_KEY", default=_UNSET) _SESSION_ID: ContextVar = ContextVar("HERMES_SESSION_ID", default=_UNSET) +# In-process UI session/window id for multi-session desktop/TUI hosts. This is +# intentionally separate from HERMES_SESSION_ID: the latter is the durable +# conversation/session-db id, while the UI id is the live frontend tab/window +# that commissioned a detached completion. Background completions use it as a +# precise return address so a stale/rotated durable session key cannot be +# consumed by whichever desktop poller wakes first. +_SESSION_UI_SESSION_ID: ContextVar = ContextVar("HERMES_UI_SESSION_ID", default=_UNSET) # ID of the message that triggered the current turn. Used as a reply anchor # so background-process notifications stay inside the originating Telegram # private-chat topic (those lanes route only with thread id + reply anchor). @@ -123,6 +130,7 @@ _VAR_MAP = { "HERMES_SESSION_USER_NAME": _SESSION_USER_NAME, "HERMES_SESSION_KEY": _SESSION_KEY, "HERMES_SESSION_ID": _SESSION_ID, + "HERMES_UI_SESSION_ID": _SESSION_UI_SESSION_ID, "HERMES_SESSION_MESSAGE_ID": _SESSION_MESSAGE_ID, "HERMES_SESSION_PROFILE": _SESSION_PROFILE, "HERMES_CRON_AUTO_DELIVER_PLATFORM": _CRON_AUTO_DELIVER_PLATFORM, @@ -160,6 +168,7 @@ def set_session_vars( profile: str = "", cwd: str = "", async_delivery: bool = True, + ui_session_id: str = "", ) -> list: """Set all session context variables and return reset tokens. @@ -191,6 +200,7 @@ def set_session_vars( _SESSION_USER_NAME.set(user_name), _SESSION_KEY.set(session_key), _SESSION_ID.set(session_id), + _SESSION_UI_SESSION_ID.set(ui_session_id), _SESSION_MESSAGE_ID.set(message_id), _SESSION_PROFILE.set(profile), _SESSION_ASYNC_DELIVERY.set(bool(async_delivery)), @@ -225,6 +235,7 @@ def clear_session_vars(tokens: list) -> None: _SESSION_USER_NAME, _SESSION_KEY, _SESSION_ID, + _SESSION_UI_SESSION_ID, _SESSION_MESSAGE_ID, _SESSION_PROFILE, ): diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 50a9fa8d5ce6..cc6ff4f5b723 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -2112,14 +2112,85 @@ def test_notification_event_routing_by_session_key(monkeypatch): monkeypatch.setattr(server, "_sessions", {"a": mine, "b": other}) # My own event → handle it. - assert server._notification_event_belongs_elsewhere(mine, {"session_key": "mine"}) is False + assert server._notification_event_belongs_elsewhere("a", mine, {"session_key": "mine"}) is False # Global/system event with no owner → handle it. - assert server._notification_event_belongs_elsewhere(mine, {"session_key": ""}) is False - assert server._notification_event_belongs_elsewhere(mine, {}) is False + assert server._notification_event_belongs_elsewhere("a", mine, {"session_key": ""}) is False + assert server._notification_event_belongs_elsewhere("a", mine, {}) is False # Owned by another *live* session → defer to that session's poller. - assert server._notification_event_belongs_elsewhere(mine, {"session_key": "other"}) is True + assert server._notification_event_belongs_elsewhere("a", mine, {"session_key": "other"}) is True # Owner is gone (not in _sessions) → handle as fallback so it isn't lost. - assert server._notification_event_belongs_elsewhere(mine, {"session_key": "ghost"}) is False + assert server._notification_event_belongs_elsewhere("a", mine, {"session_key": "ghost"}) is False + + +def test_async_delegation_event_prefers_origin_ui_session(monkeypatch): + """Detached subagent completions return to the commissioning TUI tab. + + Regression: when the durable session key was stale/orphaned, whichever + desktop poller woke first could consume the async result and inject it into + an unrelated session. + """ + mine = _session(session_key="current-key") + other = _session(session_key="unrelated-key") + monkeypatch.setattr(server, "_sessions", {"origin-sid": mine, "other-sid": other}) + monkeypatch.setattr(server, "_get_db", lambda: None) + evt = { + "type": "async_delegation", + "session_key": "stale-or-rotated-key", + "origin_ui_session_id": "origin-sid", + } + + assert server._notification_event_belongs_elsewhere("other-sid", other, evt) is True + assert server._notification_event_belongs_elsewhere("origin-sid", mine, evt) is False + + +def test_notification_event_follows_compression_continuation(monkeypatch): + """Events keyed to a compressed parent route to the live continuation.""" + old_parent = _session(session_key="old-parent") + live_tip = _session(session_key="new-tip") + monkeypatch.setattr(server, "_sessions", {"old-sid": old_parent, "tip-sid": live_tip}) + + class _DB: + def resolve_resume_session_id(self, session_id): + return "new-tip" if session_id == "old-parent" else session_id + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + evt = {"type": "async_delegation", "session_key": "old-parent"} + + assert server._notification_event_belongs_elsewhere("old-sid", old_parent, evt) is True + assert server._notification_event_belongs_elsewhere("tip-sid", live_tip, evt) is False + # A third session must leave it alone for the continuation's poller. + third = _session(session_key="third") + monkeypatch.setattr( + server, + "_sessions", + {"old-sid": old_parent, "tip-sid": live_tip, "third-sid": third}, + ) + assert server._notification_event_belongs_elsewhere("third-sid", third, evt) is True + + +def test_finalized_origin_ui_session_falls_back_to_live_continuation(monkeypatch): + """A closed origin tab must not steal its resumed continuation's result.""" + finalized_origin = _session(session_key="old-parent", _finalized=True) + live_tip = _session(session_key="new-tip") + monkeypatch.setattr( + server, + "_sessions", + {"origin-sid": finalized_origin, "tip-sid": live_tip}, + ) + + class _DB: + def resolve_resume_session_id(self, session_id): + return "new-tip" if session_id == "old-parent" else session_id + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + evt = { + "type": "async_delegation", + "session_key": "old-parent", + "origin_ui_session_id": "origin-sid", + } + + assert server._notification_event_belongs_elsewhere("origin-sid", finalized_origin, evt) is True + assert server._notification_event_belongs_elsewhere("tip-sid", live_tip, evt) is False def test_prompt_submit_rejects_negative_truncate_ordinal(monkeypatch): diff --git a/tests/tools/test_async_delegation.py b/tests/tools/test_async_delegation.py index 0cbd9313cfb4..9a473394b514 100644 --- a/tests/tools/test_async_delegation.py +++ b/tests/tools/test_async_delegation.py @@ -289,6 +289,68 @@ def test_delegate_task_background_routes_async_and_does_not_block(monkeypatch): assert "the real task" in text +def test_delegate_task_background_uses_live_tui_agent_session_id(monkeypatch): + """TUI async delegation must route to the live/compressed agent id. + + Regression: delegate_task captured the stale approval/session context key + after compression rotated parent_agent.session_id. The resulting completion + was orphaned and could be consumed by an unrelated desktop session poller. + """ + import json + from unittest.mock import MagicMock + import tools.delegate_tool as dt + from gateway.session_context import clear_session_vars, set_session_vars + from tools.approval import reset_current_session_key, set_current_session_key + + parent = MagicMock() + parent._delegate_depth = 0 + parent.session_id = "post-compress-tip" + parent._interrupt_requested = False + parent._active_children = [] + parent._active_children_lock = None + fake_child = MagicMock() + fake_child._delegate_role = "leaf" + + creds = { + "model": "m", "provider": None, "base_url": None, "api_key": None, + "api_mode": None, "command": None, "args": None, + } + monkeypatch.setattr(dt, "_build_child_agent", lambda **kw: fake_child) + monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: creds) + monkeypatch.setattr( + dt, + "_run_single_child", + lambda *a, **k: { + "task_index": 0, + "status": "completed", + "summary": "done", + "api_calls": 1, + "duration_seconds": 0.1, + "model": "m", + "exit_reason": "completed", + }, + ) + + approval_token = set_current_session_key("pre-compress-parent") + session_tokens = set_session_vars( + source="tui", + session_key="pre-compress-parent", + ui_session_id="origin-tab", + ) + try: + out = dt.delegate_task(goal="bg task", background=True, parent_agent=parent) + assert json.loads(out)["status"] == "dispatched" + evt = _drain_one() + finally: + reset_current_session_key(approval_token) + clear_session_vars(session_tokens) + + assert evt is not None + assert evt["type"] == "async_delegation" + assert evt["session_key"] == "post-compress-tip" + assert evt["origin_ui_session_id"] == "origin-tab" + + def test_delegate_task_background_batch_runs_as_one_unit(monkeypatch): """A multi-item batch with background=True dispatches the WHOLE fan-out as ONE background unit (one handle, one async slot). The children run in diff --git a/tools/async_delegation.py b/tools/async_delegation.py index f28156e2f57a..710ab5c1d3b5 100644 --- a/tools/async_delegation.py +++ b/tools/async_delegation.py @@ -130,6 +130,7 @@ def dispatch_async_delegation( model: Optional[str], session_key: str, runner: Callable[[], Dict[str, Any]], + origin_ui_session_id: str = "", interrupt_fn: Optional[Callable[[], None]] = None, max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN, ) -> Dict[str, Any]: @@ -172,6 +173,7 @@ def dispatch_async_delegation( "role": role, "model": model, "session_key": session_key, + "origin_ui_session_id": origin_ui_session_id, "status": "running", "dispatched_at": dispatched_at, "completed_at": None, @@ -282,6 +284,7 @@ def _push_completion_event( # session_key routes the completion back to the originating gateway # session; empty string => CLI (single-session) path. "session_key": record.get("session_key", ""), + "origin_ui_session_id": record.get("origin_ui_session_id", ""), "goal": record.get("goal", ""), "context": record.get("context"), "toolsets": record.get("toolsets"), @@ -317,6 +320,7 @@ def dispatch_async_delegation_batch( model: Optional[str], session_key: str, runner: Callable[[], Dict[str, Any]], + origin_ui_session_id: str = "", interrupt_fn: Optional[Callable[[], None]] = None, max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN, ) -> Dict[str, Any]: @@ -356,6 +360,7 @@ def dispatch_async_delegation_batch( "role": role, "model": model, "session_key": session_key, + "origin_ui_session_id": origin_ui_session_id, "status": "running", "dispatched_at": dispatched_at, "completed_at": None, @@ -453,6 +458,7 @@ def _finalize_batch( "type": "async_delegation", "delegation_id": delegation_id, "session_key": event_record.get("session_key", ""), + "origin_ui_session_id": event_record.get("origin_ui_session_id", ""), "goal": event_record.get("goal", ""), "goals": event_record.get("goals"), "context": event_record.get("context"), diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index b989b7983b19..4179926091a3 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2796,6 +2796,25 @@ def delegate_task( return json.dumps(_sync_result, ensure_ascii=False) _session_key = get_current_session_key(default="") + _origin_ui_session_id = "" + try: + from gateway.session_context import get_session_env + + _source = get_session_env("HERMES_SESSION_SOURCE", "") + _origin_ui_session_id = get_session_env("HERMES_UI_SESSION_ID", "") + # In desktop/TUI, the routable session key is the durable + # AIAgent.session_id. Context compression can rotate that id during + # the same turn before the TUI-side session dict is re-anchored; + # if we capture the stale approval/session context key here, the + # async completion becomes an orphan and any desktop poller may + # consume it. Gateway chats are different: their session_key is the + # platform conversation key (agent:main:...), so keep it there. + if _source == "tui": + _agent_session_id = str(getattr(parent_agent, "session_id", "") or "") + if _agent_session_id: + _session_key = _agent_session_id + except Exception: + _origin_ui_session_id = "" _child_agents = [c for (_, _, c) in children] # Detach every child from the parent's interrupt-propagation list — the @@ -2836,6 +2855,7 @@ def delegate_task( role=top_role, model=creds["model"], session_key=_session_key, + origin_ui_session_id=_origin_ui_session_id, runner=_batch_runner, interrupt_fn=_batch_interrupt, max_async_children=_get_max_async_children(), diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 8092147bc79c..29c7dd42d200 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1946,7 +1946,12 @@ def _cwd_for_session_key(session_key: str) -> str: return "" -def _set_session_context(session_key: str, cwd: str | None = None) -> list: +def _set_session_context( + session_key: str, + cwd: str | None = None, + *, + ui_session_id: str = "", +) -> list: try: from gateway.session_context import set_session_vars @@ -1961,7 +1966,12 @@ def _set_session_context(session_key: str, cwd: str | None = None) -> list: if sess.get("session_key") == session_key: source = _session_source(sess) break - return set_session_vars(session_key=session_key, source=source, cwd=resolved) + return set_session_vars( + session_key=session_key, + source=source, + cwd=resolved, + ui_session_id=ui_session_id, + ) except Exception: return [] @@ -8451,22 +8461,76 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"status": "streaming"}) -def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool: +def _notification_event_belongs_elsewhere(sid: str, session: dict, evt: dict) -> bool: """True if ``evt`` is owned by a *different* live session. - Background-process events carry the ``session_key`` of the session that - started the process. Since all desktop sessions share one process-wide - completion queue, each poller must skip events it doesn't own so a - background job's completion surfaces in the session that launched it — not - whichever poller happened to dequeue first. Orphaned events (owner gone) - and global/system events (empty ``session_key``) return False so the - current poller still handles them rather than losing them. + Background completions carry the ``session_key`` of the session that started + the work. Async delegation completions from the desktop also carry + ``origin_ui_session_id``: the live TUI tab/window that commissioned them. + Since all desktop sessions share one process-wide completion queue, each + poller must skip events it doesn't own so a detached result surfaces in the + launching session, not whichever poller happened to dequeue first. """ + evt_ui_sid = str(evt.get("origin_ui_session_id") or "") + if evt_ui_sid: + if evt_ui_sid == str(sid or "") and not session.get("_finalized"): + return False + try: + with _sessions_lock: + owner_live = evt_ui_sid in _sessions and not _sessions[evt_ui_sid].get("_finalized") + except Exception: + owner_live = False + if owner_live: + return True + # If the exact UI tab is gone, fall through to durable session_key + # routing. That avoids wrong-session delivery while still allowing a + # resumed continuation with the same durable key/lineage to claim it. + evt_key = str(evt.get("session_key") or "") if not evt_key: return False - if evt_key == str(session.get("session_key") or ""): + + current_keys = { + str(session.get("session_key") or ""), + _session_lookup_key(session, fallback=sid), + } + + # Compression can rotate AIAgent.session_id while the detached child is + # still running. Resolve the event's original key to its continuation tip so + # an event captured before or after compression still maps to the same live + # desktop session instead of becoming an orphan that any poller may consume. + resolved_key = evt_key + try: + db = _get_db() + if db is not None: + resolved_key = db.resolve_resume_session_id(evt_key) or evt_key + except Exception: + resolved_key = evt_key + + # If the key has a live continuation, prefer that continuation over the + # compressed parent. Otherwise a stale parent tab could consume the event + # before the real current conversation sees it. + if resolved_key != evt_key: + if resolved_key in current_keys: + return False + try: + with _sessions_lock: + continuation_live = any( + not s.get("_finalized") + and ( + str(s.get("session_key") or "") == resolved_key + or _session_lookup_key(s, fallback="") == resolved_key + ) + for s in _sessions.values() + ) + except Exception: + continuation_live = False + if continuation_live: + return True + + if evt_key in current_keys: return False + try: with _sessions_lock: snapshot = list(_sessions.values()) @@ -8476,7 +8540,12 @@ def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool: return False return any( - s is not session and str(s.get("session_key") or "") == evt_key + s is not session + and not s.get("_finalized") + and ( + str(s.get("session_key") or "") in {evt_key, resolved_key} + or _session_lookup_key(s, fallback="") in {evt_key, resolved_key} + ) for s in snapshot ) @@ -8546,7 +8615,7 @@ def _notification_poller_loop( # process started in session A would surface its completion in whichever # session's poller happened to wake first (Ben's "reported in a # different session" bug). Leave foreign events for their owner. - if _notification_event_belongs_elsewhere(session, evt): + if _notification_event_belongs_elsewhere(sid, session, evt): process_registry.completion_queue.put(evt) time.sleep(0.1) continue @@ -8604,7 +8673,7 @@ def _notification_poller_loop( evt = process_registry.completion_queue.get_nowait() except Exception: break - if _notification_event_belongs_elsewhere(session, evt): + if _notification_event_belongs_elsewhere(sid, session, evt): deferred.append(evt) continue _evt_sid = evt.get("session_id", "") @@ -8729,7 +8798,10 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: ) approval_token = set_current_session_key(session["session_key"]) - session_tokens = _set_session_context(session["session_key"]) + session_tokens = _set_session_context( + session["session_key"], + ui_session_id=sid, + ) _profile_home_str = session.get("profile_home") if _profile_home_str: home_token = set_hermes_home_override(_profile_home_str) From 4b27be11148011ca5fba5c9439e1f5490bedde3f Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:48:25 -0700 Subject: [PATCH 215/610] fix(delegation): fail-closed orphan handling + session-scoped delegation lifecycle Two invariants layered on the origin-routing commit (#55578): 1. Fail closed on orphaned async-delegation payloads. The poller's belongs-elsewhere check handles events owned by another LIVE session, but an event whose owner is gone previously fell through and was adopted by whichever poller saw it - injecting one chat's delegation output into another chat. Delegation completions are now injected only into a session that PROVABLY owns them (origin UI id, or session-key/lineage match via the compression chain); unowned payloads are dropped from injection with a WARNING (the subagent's output is already persisted in the delegation records, so nothing is lost). The shutdown drain applies the same rule. Non-delegation events keep the historical adopt-orphans behavior. 2. A session's in-flight async delegations end with the session. _finalize_session now calls interrupt_for_session(): delegations commissioned by the closing UI session are interrupted always; key-matched delegations only when the TUI owns the session lifecycle, so closing a viewer tab on a live gateway session never kills the gateway's own background work. --- .../test_delegation_session_lifecycle.py | 158 ++++++++++++++++++ tools/async_delegation.py | 48 ++++++ tui_gateway/server.py | 105 +++++++++++- 3 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 tests/tui_gateway/test_delegation_session_lifecycle.py diff --git a/tests/tui_gateway/test_delegation_session_lifecycle.py b/tests/tui_gateway/test_delegation_session_lifecycle.py new file mode 100644 index 000000000000..57a96469e230 --- /dev/null +++ b/tests/tui_gateway/test_delegation_session_lifecycle.py @@ -0,0 +1,158 @@ +"""Fail-closed ownership + session-scoped delegation lifecycle (#55578). + +Covers the two hardening rules layered on top of the origin-routing salvage: + +1. ``_session_owns_notification_event`` — positive-proof ownership. An + async-delegation completion may only be injected into a session that + PROVABLY commissioned it (origin UI id, or session-key/lineage match). + Orphans are never adopted by a foreign chat. + +2. ``interrupt_for_session`` — a session's in-flight async delegations end + with the session. ``_finalize_session`` interrupts delegations owned by + the closing session (by origin UI id always; by durable key only when the + TUI owns the lifecycle). +""" + +import threading +from unittest.mock import MagicMock, patch + +import pytest + +import tools.async_delegation as ad +from tui_gateway.server import ( + _finalize_session, + _session_owns_notification_event, +) + + +@pytest.fixture(autouse=True) +def _reset_async_delegation(): + ad._reset_for_tests() + yield + ad._reset_for_tests() + + +class TestSessionOwnsNotificationEvent: + def _session(self, key="sess_key_1"): + return {"session_key": key, "_finalized": False} + + def test_origin_ui_match_owns(self): + evt = {"type": "async_delegation", "origin_ui_session_id": "tab1", "session_key": "other"} + assert _session_owns_notification_event("tab1", self._session(), evt) is True + + def test_session_key_match_owns(self): + evt = {"type": "async_delegation", "origin_ui_session_id": "", "session_key": "sess_key_1"} + assert _session_owns_notification_event("tabX", self._session("sess_key_1"), evt) is True + + def test_orphan_is_not_owned(self): + """No origin match, no key match, owner gone → NOT ours (fail closed).""" + evt = {"type": "async_delegation", "origin_ui_session_id": "dead_tab", "session_key": "gone_key"} + assert _session_owns_notification_event("tab1", self._session(), evt) is False + + def test_empty_key_and_origin_not_owned(self): + """A delegation event with no return address at all is never adopted.""" + evt = {"type": "async_delegation", "origin_ui_session_id": "", "session_key": ""} + assert _session_owns_notification_event("tab1", self._session(), evt) is False + + def test_finalized_session_owns_nothing(self): + evt = {"type": "async_delegation", "origin_ui_session_id": "tab1", "session_key": "sess_key_1"} + sess = self._session() + sess["_finalized"] = True + assert _session_owns_notification_event("tab1", sess, evt) is False + + def test_compression_chain_resolution_owns(self): + evt = {"type": "async_delegation", "origin_ui_session_id": "", "session_key": "parent_key"} + db = MagicMock() + db.resolve_resume_session_id.return_value = "child_key" + with patch("tui_gateway.server._get_db", return_value=db): + assert _session_owns_notification_event("tabX", self._session("child_key"), evt) is True + + +class TestInterruptForSession: + def _seed_record(self, delegation_id, session_key="", origin_ui_session_id="", status="running"): + fn = MagicMock() + with ad._records_lock: + ad._records[delegation_id] = { + "delegation_id": delegation_id, + "status": status, + "session_key": session_key, + "origin_ui_session_id": origin_ui_session_id, + "interrupt_fn": fn, + } + return fn + + def test_interrupts_only_matching_session(self): + mine = self._seed_record("d1", session_key="sess_A") + other = self._seed_record("d2", session_key="sess_B") + n = ad.interrupt_for_session(session_key="sess_A") + assert n == 1 + mine.assert_called_once() + other.assert_not_called() + + def test_matches_by_origin_ui_session_id(self): + mine = self._seed_record("d1", origin_ui_session_id="tab1") + other = self._seed_record("d2", origin_ui_session_id="tab2") + n = ad.interrupt_for_session(origin_ui_session_id="tab1") + assert n == 1 + mine.assert_called_once() + other.assert_not_called() + + def test_no_selector_is_noop(self): + fn = self._seed_record("d1", session_key="sess_A") + assert ad.interrupt_for_session() == 0 + fn.assert_not_called() + + def test_completed_records_untouched(self): + fn = self._seed_record("d1", session_key="sess_A", status="completed") + assert ad.interrupt_for_session(session_key="sess_A") == 0 + fn.assert_not_called() + + +class TestFinalizeInterruptsOwnDelegations: + def _make_session(self, session_key="sess_A", sid="tab1"): + agent = MagicMock() + agent.session_id = session_key + agent._session_messages = None + agent.model = "m" + agent.platform = "tui" + return { + "agent": agent, + "history": [{"role": "user", "content": "x"}], + "history_lock": threading.Lock(), + "session_key": session_key, + "_finalized": False, + "_sid": sid, + } + + @patch("tui_gateway.server._get_db") + def test_finalize_interrupts_sessions_delegations(self, mock_get_db): + mock_db = MagicMock() + mock_db.get_session.return_value = {"source": "tui"} + mock_get_db.return_value = mock_db + + with patch("tools.async_delegation.interrupt_for_session") as mock_int: + _finalize_session(self._make_session(), end_reason="tui_close") + + mock_int.assert_called_once() + kwargs = mock_int.call_args.kwargs + assert kwargs["session_key"] == "sess_A" + assert kwargs["origin_ui_session_id"] == "tab1" + + @patch("tui_gateway.server._get_db") + def test_viewer_of_gateway_session_only_interrupts_by_origin(self, mock_get_db): + """Closing a TUI viewer tab on a live gateway session must not kill + the gateway's own background work — key-based interrupt is skipped, + origin-id interrupt (this tab's own dispatches) still applies.""" + mock_db = MagicMock() + mock_db.get_session.return_value = {"source": "telegram"} + mock_get_db.return_value = mock_db + + with patch("tools.async_delegation.interrupt_for_session") as mock_int: + _finalize_session( + self._make_session(session_key="agent:main:telegram:dm:123", sid="tab9"), + end_reason="ws_orphan_reap", + ) + + kwargs = mock_int.call_args.kwargs + assert kwargs["session_key"] == "" + assert kwargs["origin_ui_session_id"] == "tab9" diff --git a/tools/async_delegation.py b/tools/async_delegation.py index 710ab5c1d3b5..cc22a7f6e9ab 100644 --- a/tools/async_delegation.py +++ b/tools/async_delegation.py @@ -525,6 +525,54 @@ def interrupt_all(reason: str = "shutdown") -> int: return count +def interrupt_for_session( + session_key: str = "", + origin_ui_session_id: str = "", + reason: str = "session_end", +) -> int: + """Signal running async delegations owned by ONE session to stop. + + A delegation's lifecycle is bound to the session that spawned it: when + that session ends, its in-flight background subagents must end with it — + a completed orphan would otherwise sit on the shared completion queue + with no live owner, either leaking into another chat or burning tokens + with no one listening (#55578). + + Matches on ``origin_ui_session_id`` (the live UI session that + commissioned the work) and/or the durable ``session_key``; either + matching field claims the record. Returns how many were interrupted. + """ + if not session_key and not origin_ui_session_id: + return 0 + count = 0 + with _records_lock: + targets = [ + r for r in _records.values() + if r.get("status") == "running" + and ( + (origin_ui_session_id and str(r.get("origin_ui_session_id") or "") == origin_ui_session_id) + or (session_key and str(r.get("session_key") or "") == session_key) + ) + ] + for r in targets: + fn = r.get("interrupt_fn") + if callable(fn): + try: + fn() + count += 1 + except Exception as exc: + logger.debug( + "interrupt_for_session: %s interrupt failed: %s", + r.get("delegation_id"), exc, + ) + if count: + logger.info( + "Interrupted %d async delegation(s) for ending session (%s)", + count, reason, + ) + return count + + def _reset_for_tests() -> None: """Test-only: clear all state and tear down the executor.""" global _executor, _executor_max_workers diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 29c7dd42d200..80fa9cea95ed 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -625,6 +625,7 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No # Use session_id (from agent.session_id) not session_key — after compression, # session_key may be stale (the ended parent) while session_id is the live # continuation. Fix for #20001. + _tui_owns_lifecycle = True if session_id: try: db = _get_db() @@ -638,11 +639,40 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No # repeats on every inbound message. (#60609) row = db.get_session(session_id) source = (row or {}).get("source", "") - if not _is_gateway_owned_source(source): + _tui_owns_lifecycle = not _is_gateway_owned_source(source) + if _tui_owns_lifecycle: db.end_session(session_id, end_reason) except Exception: pass + # A session's in-flight async delegations end WITH the session (#55578): + # once nobody owns the return address, a still-running background subagent + # can only burn tokens and park an orphaned completion on the shared + # queue. Always interrupt delegations commissioned by THIS live UI session + # (its sid); additionally interrupt by durable session_key, but only when + # the TUI owns the lifecycle — closing a viewer tab on a live gateway + # session must not kill the gateway's own background work. + try: + from tools.async_delegation import interrupt_for_session + + _own_sid = str(session.get("_sid") or "") + if not _own_sid: + try: + with _sessions_lock: + for _cand_sid, _cand in _sessions.items(): + if _cand is session: + _own_sid = _cand_sid + break + except Exception: + _own_sid = "" + interrupt_for_session( + session_key=str(session_key or "") if _tui_owns_lifecycle else "", + origin_ui_session_id=_own_sid, + reason=end_reason, + ) + except Exception: + pass + # Close the slash-worker subprocess as part of finalize itself, not just # in the callers. Defense-in-depth: every session-end path goes through # _finalize_session (it's the single ``_finalized``-guarded chokepoint), so @@ -712,6 +742,10 @@ def _close_session_by_id(sid: str, *, end_reason: str = "tui_close") -> bool: session = _sessions.pop(sid, None) if session is None: return False + # The session is already out of _sessions here, so downstream teardown + # (e.g. _finalize_session's per-session async-delegation interrupt) can't + # recover its live id by scanning the dict — stamp it on the record. + session["_sid"] = sid _teardown_session(session, end_reason=end_reason) return True @@ -8550,6 +8584,40 @@ def _notification_event_belongs_elsewhere(sid: str, session: dict, evt: dict) -> ) +def _session_owns_notification_event(sid: str, session: dict, evt: dict) -> bool: + """True iff *this* session PROVABLY owns ``evt``. + + Positive ownership — the mirror of ``_notification_event_belongs_elsewhere`` + minus its orphan-adoption fallback. An event owns-matches when its + ``origin_ui_session_id`` is this live session, or its ``session_key`` + (raw or resolved through the compression chain) matches this session's + key/lineage. Used as a fail-closed gate for async-delegation payloads: + "not provably elsewhere" is NOT good enough to inject a conversation + payload into this chat (#55578). + """ + if session.get("_finalized"): + return False + if str(evt.get("origin_ui_session_id") or "") == str(sid or ""): + return True + evt_key = str(evt.get("session_key") or "") + if not evt_key: + return False + current_keys = { + str(session.get("session_key") or ""), + _session_lookup_key(session, fallback=sid), + } + if evt_key in current_keys: + return True + try: + db = _get_db() + resolved_key = ( + db.resolve_resume_session_id(evt_key) if db is not None else evt_key + ) or evt_key + except Exception: + resolved_key = evt_key + return resolved_key in current_keys + + def _notification_event_dedup_key(evt: dict) -> tuple: """Return the UI-emission identity for a process notification event. @@ -8620,6 +8688,32 @@ def _notification_poller_loop( time.sleep(0.1) continue + # Fail closed for async-delegation results (#55578): these carry a + # conversation payload, and injecting one into any chat other than the + # one that commissioned it is a hard cross-session leak. The + # belongs-elsewhere check above already re-queued events owned by + # another LIVE session; what reaches here is either ours or an + # orphan whose owner is gone. Orphaned delegation payloads are + # DROPPED, not adopted — the subagent's summary is already persisted + # in the delegation records/output store, so nothing is lost, whereas + # a wrong-chat injection is unrecoverable. Non-delegation events + # (background process completions etc.) keep the historical + # adopt-orphans behavior. + if evt.get("type") == "async_delegation" and not _session_owns_notification_event( + sid, session, evt + ): + logger.warning( + "async-delegation completion %s has no live owner " + "(origin=%r key=%r); dropping from injection instead of " + "delivering to session %s (#55578 fail-closed; result " + "remains in the delegation records)", + evt.get("delegation_id", "?"), + str(evt.get("origin_ui_session_id") or ""), + str(evt.get("session_key") or ""), + sid, + ) + continue + _evt_sid = evt.get("session_id", "") if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid): continue @@ -8676,6 +8770,15 @@ def _notification_poller_loop( if _notification_event_belongs_elsewhere(sid, session, evt): deferred.append(evt) continue + # Same fail-closed rule as the live loop: an orphaned async-delegation + # payload is never adopted by a foreign session — defer it (a later + # resume of the owner's lineage can still claim it) rather than + # injecting another chat's conversation here (#55578). + if evt.get("type") == "async_delegation" and not _session_owns_notification_event( + sid, session, evt + ): + deferred.append(evt) + continue _evt_sid = evt.get("session_id", "") if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid): continue From b64b802131ff6db8127d275f242fb0eb09c7af48 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:09:08 -0700 Subject: [PATCH 216/610] feat(models): swap curated Tencent Hy3 Preview for GA tencent/hy3, drop owl-alpha (#60943) - OPENROUTER_MODELS: remove openrouter/owl-alpha (free) and tencent/hy3-preview{,:free}; add tencent/hy3 and tencent/hy3:free - _PROVIDER_MODELS[nous]: tencent/hy3-preview -> tencent/hy3 - run_agent.py reasoning-prefix list: tencent/hy3-preview -> tencent/hy3 (prefix match still covers -preview if pinned) - model_metadata: register hy3 context length (262144) alongside hy3-preview - regenerate website/static/api/model-catalog.json - update tokenhub curated-list tests to the new IDs The tencent-tokenhub direct provider still serves hy3-preview and is intentionally unchanged. --- agent/model_metadata.py | 2 ++ hermes_cli/models.py | 7 +++---- run_agent.py | 2 +- .../hermes_cli/test_tencent_tokenhub_provider.py | 16 ++++++++-------- website/static/api/model-catalog.json | 12 ++++-------- 5 files changed, 18 insertions(+), 21 deletions(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index ba87a6dc496d..c9a7d5771a85 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -305,6 +305,8 @@ DEFAULT_CONTEXT_LENGTHS = { # OpenRouter live metadata reports 262144 (256 × 1024); align the # static fallback so cache and offline both agree (issue #22268). "hy3-preview": 262144, + # Tencent — Hy3 (GA successor to Hy3 Preview), same 256K window. + "hy3": 262144, # Nemotron — NVIDIA's open-weights series (128K context across all sizes) "nemotron": 131072, # Arcee diff --git a/hermes_cli/models.py b/hermes_cli/models.py index f8ddf1ab33cb..e19b99d4b7ca 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -67,7 +67,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ # Xiaomi ("xiaomi/mimo-v2.5-pro", ""), # Tencent - ("tencent/hy3-preview", ""), + ("tencent/hy3", ""), # StepFun ("stepfun/step-3.7-flash", ""), # NVIDIA @@ -78,9 +78,8 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ ("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"), # Free tier ("openrouter/elephant-alpha", "free"), - ("openrouter/owl-alpha", "free"), ("poolside/laguna-m.1:free", "free"), - ("tencent/hy3-preview:free", "free"), + ("tencent/hy3:free", "free"), ("nvidia/nemotron-3-super-120b-a12b:free", "free"), ("nvidia/nemotron-3-ultra-550b-a55b:free", "free"), ("inclusionai/ring-2.6-1t:free", "free"), @@ -211,7 +210,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = { # Xiaomi "xiaomi/mimo-v2.5-pro", # Tencent - "tencent/hy3-preview", + "tencent/hy3", # StepFun "stepfun/step-3.7-flash", # NVIDIA diff --git a/run_agent.py b/run_agent.py index 3be621f217f2..a20a70248634 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5337,7 +5337,7 @@ class AIAgent: "google/gemini-2", "google/gemma-4", "qwen/qwen3", - "tencent/hy3-preview", + "tencent/hy3", "xiaomi/", ) return any(model.startswith(prefix) for prefix in reasoning_model_prefixes) diff --git a/tests/hermes_cli/test_tencent_tokenhub_provider.py b/tests/hermes_cli/test_tencent_tokenhub_provider.py index 55ab42244c82..508e803c2253 100644 --- a/tests/hermes_cli/test_tencent_tokenhub_provider.py +++ b/tests/hermes_cli/test_tencent_tokenhub_provider.py @@ -191,22 +191,22 @@ class TestTencentTokenhubCanonicalProvider: class TestTencentInOpenRouterAndNous: - """tencent/hy3-preview:free and tencent/hy3-preview should appear in OpenRouter and Nous curated lists.""" + """tencent/hy3:free and tencent/hy3 should appear in OpenRouter and Nous curated lists.""" def test_in_openrouter_fallback(self): from hermes_cli.models import OPENROUTER_MODELS ids = [mid for mid, _ in OPENROUTER_MODELS] - assert "tencent/hy3-preview:free" in ids + assert "tencent/hy3:free" in ids def test_paid_in_openrouter_fallback(self): - """tencent/hy3-preview (paid, no :free suffix) should also be in OpenRouter list.""" + """tencent/hy3 (paid, no :free suffix) should also be in OpenRouter list.""" from hermes_cli.models import OPENROUTER_MODELS ids = [mid for mid, _ in OPENROUTER_MODELS] - assert "tencent/hy3-preview" in ids + assert "tencent/hy3" in ids def test_in_nous_provider_models(self): from hermes_cli.models import _PROVIDER_MODELS - assert "tencent/hy3-preview" in _PROVIDER_MODELS["nous"] + assert "tencent/hy3" in _PROVIDER_MODELS["nous"] # ============================================================================= @@ -433,7 +433,7 @@ class TestTencentTokenhubCLIDispatch: class TestTencentTokenhubModelCatalogJSON: - """Verify tencent/hy3-preview:free and tencent/hy3-preview are present in the website model-catalog.json.""" + """Verify tencent/hy3:free and tencent/hy3 are present in the website model-catalog.json.""" def test_in_model_catalog_json(self): catalog_path = os.path.join( @@ -457,8 +457,8 @@ class TestTencentTokenhubModelCatalogJSON: for provider_entry in providers: for model in provider_entry.get("models", []): all_ids.add(model.get("id", "")) - assert "tencent/hy3-preview:free" in all_ids - assert "tencent/hy3-preview" in all_ids + assert "tencent/hy3:free" in all_ids + assert "tencent/hy3" in all_ids # ============================================================================= diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index 180707d9b081..d69dc3af4afa 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-07-01T20:08:52Z", + "updated_at": "2026-07-08T13:38:17Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" @@ -105,7 +105,7 @@ "description": "" }, { - "id": "tencent/hy3-preview", + "id": "tencent/hy3", "description": "" }, { @@ -128,16 +128,12 @@ "id": "openrouter/elephant-alpha", "description": "free" }, - { - "id": "openrouter/owl-alpha", - "description": "free" - }, { "id": "poolside/laguna-m.1:free", "description": "free" }, { - "id": "tencent/hy3-preview:free", + "id": "tencent/hy3:free", "description": "free" }, { @@ -227,7 +223,7 @@ "id": "xiaomi/mimo-v2.5-pro" }, { - "id": "tencent/hy3-preview" + "id": "tencent/hy3" }, { "id": "stepfun/step-3.7-flash" From ee0b54e16cbbd1ca96d4a1facdcef683c1608696 Mon Sep 17 00:00:00 2001 From: waroffchange <116298975+waroffchange@users.noreply.github.com> Date: Wed, 8 Jul 2026 05:03:51 +0300 Subject: [PATCH 217/610] docs(i18n): align translated CONTRIBUTING files with pyproject Python range (3.11-3.13) --- CONTRIBUTING.es.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.es.md b/CONTRIBUTING.es.md index ab34206dd6c3..78c80113c6e4 100644 --- a/CONTRIBUTING.es.md +++ b/CONTRIBUTING.es.md @@ -74,7 +74,7 @@ Esto no es una barra de calidad — es una decisión de acoplamiento y mantenimi | Requisito | Notas | |-----------|-------| | **Git** | Con la extensión `git-lfs` instalada | -| **Python 3.11+** | uv lo instalará si falta | +| **Python 3.11–3.13** | uv lo instalará si falta | | **uv** | Gestor de paquetes Python rápido ([instalar](https://docs.astral.sh/uv/)) | | **Node.js 20+** | Opcional — necesario para herramientas de navegador y puente WhatsApp (coincide con los engines de `package.json` raíz) | From 5057f03bfdce539b3a2ed28b84920586f9c54338 Mon Sep 17 00:00:00 2001 From: waroffchange <116298975+waroffchange@users.noreply.github.com> Date: Wed, 8 Jul 2026 05:03:53 +0300 Subject: [PATCH 218/610] docs(i18n): align translated CONTRIBUTING files with pyproject Python range (3.11-3.13) --- .../current/developer-guide/contributing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/contributing.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/contributing.md index 8a4547f35d5f..480018a1e1b5 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/contributing.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/contributing.md @@ -34,7 +34,7 @@ description: "如何为 Hermes Agent 做贡献 — 开发环境配置、代码 | 要求 | 说明 | |-------------|-------| | **Git** | 需安装 `git-lfs` 扩展 | -| **Python 3.11+** | 若未安装,uv 会自动安装 | +| **Python 3.11–3.13** | 若未安装,uv 会自动安装 | | **uv** | 高速 Python 包管理器([安装](https://docs.astral.sh/uv/)) | | **Node.js 20+** | 可选 — 浏览器工具和 WhatsApp bridge 需要(与根目录 `package.json` engines 字段一致) | From f75f3cd713995f7a242665fbcc80c8d34ba71957 Mon Sep 17 00:00:00 2001 From: Tony Simons Date: Sun, 5 Jul 2026 01:43:37 -0500 Subject: [PATCH 219/610] fix(delegation): route async delegate_task results back to originating session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The completion event already carries the dispatching session's session_key (captured at dispatch time in delegate_tool.py:2798), but the delivery router ignored it — results landed in whatever session was active at completion time instead of the session that dispatched the subagent. Changes: - drain_notifications() in process_registry.py: optional session_key filter. Non-matching async_delegation events are re-queued instead of consumed, so they remain available for the correct session's drain. - cli.py process_loop: passes active session_key to drain_notifications() - tui_gateway/server.py post-turn drain: passes session_key from the TUI session dict - gateway/run.py _build_process_event_source: logs warning when routing metadata is unresolvable (previously silent drop) - Regression tests verifying session-scoped drain filtering Fixes #58684 --- cli.py | 4 +- gateway/run.py | 7 ++ tests/tools/test_process_registry.py | 103 +++++++++++++++++++++++++++ tools/process_registry.py | 23 +++++- tui_gateway/server.py | 4 +- 5 files changed, 137 insertions(+), 4 deletions(-) diff --git a/cli.py b/cli.py index e4e85720fb2b..4f7f0e831142 100644 --- a/cli.py +++ b/cli.py @@ -15082,7 +15082,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # and watch pattern matches) while agent is idle. try: from tools.process_registry import process_registry - for _evt, _synth in process_registry.drain_notifications(): + from tools.approval import get_current_session_key + _drain_sk = get_current_session_key(default="") + for _evt, _synth in process_registry.drain_notifications(session_key=_drain_sk): self._pending_input.put(_synth) except Exception: pass diff --git a/gateway/run.py b/gateway/run.py index 527da4ff5132..7d5293ef411f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -15143,6 +15143,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew chat_type = str(evt.get("chat_type") or derived_chat_type or "").strip().lower() chat_id = str(evt.get("chat_id") or derived_chat_id or "").strip() if not platform_name or not chat_type or not chat_id: + logger.warning( + "Synthetic event source unresolvable: " + "session_key=%r platform=%r chat_type=%r chat_id=%r " + "evt_type=%s", + session_key, platform_name, chat_type, chat_id, + evt.get("type", "?"), + ) return None try: diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index 62d491bf82c5..5dac30f2a1e6 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -1346,6 +1346,109 @@ def test_drain_notifications_empty_queue(): assert results == [] +def test_drain_notifications_filters_async_delegation_by_session_key(): + """Async-delegation events should only be consumed by the matching session's drain. + + Regression test for issue #58684: background delegation results delivered + to the wrong session when the user switches sessions while a subagent runs. + """ + from tools.process_registry import process_registry + + # Clear the queue first + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + try: + # Put events for different sessions + process_registry.completion_queue.put({ + "type": "async_delegation", + "delegation_id": "deleg_session_a", + "session_key": "telegram:dm:111:user_a", + "goal": "task A", + "status": "completed", + "summary": "done A", + "api_calls": 1, + "duration_seconds": 0.5, + }) + process_registry.completion_queue.put({ + "type": "async_delegation", + "delegation_id": "deleg_session_b", + "session_key": "telegram:dm:222:user_b", + "goal": "task B", + "status": "completed", + "summary": "done B", + "api_calls": 1, + "duration_seconds": 0.3, + }) + + # Drain for session A — should only get deleg_session_a + results_a = process_registry.drain_notifications(session_key="telegram:dm:111:user_a") + assert len(results_a) == 1, ( + f"Expected 1 event for session A, got {len(results_a)}" + ) + assert results_a[0][0]["delegation_id"] == "deleg_session_a" + assert "done A" in results_a[0][1] + + # Session B's event should have been re-queued — drain for session B + results_b = process_registry.drain_notifications(session_key="telegram:dm:222:user_b") + assert len(results_b) == 1, ( + f"Expected 1 event for session B, got {len(results_b)}" + ) + assert results_b[0][0]["delegation_id"] == "deleg_session_b" + assert "done B" in results_b[0][1] + + # No more events should remain + assert process_registry.completion_queue.empty() + finally: + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + +def test_drain_notifications_no_filter_passes_all_async_delegation(): + """Without a session_key filter, all async-delegation events are consumed. + + This ensures backward compatibility — the default (session_key="") permits + all events, matching pre-fix behavior. + """ + from tools.process_registry import process_registry + + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + try: + process_registry.completion_queue.put({ + "type": "async_delegation", + "delegation_id": "deleg_1", + "session_key": "telegram:dm:111:user_a", + "goal": "task 1", + "status": "completed", + "summary": "done 1", + "api_calls": 1, + "duration_seconds": 0.5, + }) + process_registry.completion_queue.put({ + "type": "async_delegation", + "delegation_id": "deleg_2", + "session_key": "telegram:dm:222:user_b", + "goal": "task 2", + "status": "completed", + "summary": "done 2", + "api_calls": 1, + "duration_seconds": 0.3, + }) + + # No filter — both should be consumed + results = process_registry.drain_notifications() + assert len(results) == 2, ( + f"Expected 2 events without filter, got {len(results)}" + ) + ids = {r[0]["delegation_id"] for r in results} + assert ids == {"deleg_1", "deleg_2"} + finally: + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + # --------------------------------------------------------------------------- # _terminate_host_pid — cross-platform process-tree termination # --------------------------------------------------------------------------- diff --git a/tools/process_registry.py b/tools/process_registry.py index 5dfa5de7da48..ae21d94e5c08 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -1148,14 +1148,24 @@ class ProcessRegistry: """ return session_id in self._completion_consumed or session_id in self._poll_observed - def drain_notifications(self) -> "list[tuple[dict, str]]": + def drain_notifications( + self, session_key: str = "", + ) -> "list[tuple[dict, str]]": """Pop all pending notification events and return formatted pairs. Returns a list of (raw_event, formatted_text) tuples. Skips completion events the agent already consumed via wait/log or observed inline via poll() (see ``_drain_should_skip``). + + When ``session_key`` is non-empty, async-delegation events whose + ``session_key`` does NOT match are re-queued rather than consumed, + so they remain available for the correct session. This prevents + background delegation results from being delivered to the wrong + session when the user switches between gateway sessions while a + subagent is running (issue #58684). """ - results = [] + results: "list[tuple[dict, str]]" = [] + requeue: "list[dict]" = [] while not self.completion_queue.empty(): try: evt = self.completion_queue.get_nowait() @@ -1164,9 +1174,18 @@ class ProcessRegistry: _evt_sid = evt.get("session_id", "") if evt.get("type") == "completion" and self._drain_should_skip(_evt_sid): continue + # Filter async-delegation events by the caller's session_key so + # they are not delivered to the wrong session/thread (#58684). + if session_key and evt.get("type") == "async_delegation": + evt_session_key = evt.get("session_key", "") or "" + if evt_session_key != session_key: + requeue.append(evt) + continue text = format_process_notification(evt) if text: results.append((evt, text)) + for evt in requeue: + self.completion_queue.put(evt) return results def get(self, session_id: str) -> Optional[ProcessSession]: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 80fa9cea95ed..c640a4d5a6a6 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -9337,7 +9337,9 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: try: from tools.process_registry import process_registry - for _evt, synth in process_registry.drain_notifications(): + for _evt, synth in process_registry.drain_notifications( + session_key=session.get("session_key", ""), + ): with session["history_lock"]: if session.get("running"): process_registry.completion_queue.put(_evt) From 65372395eb2975152727013ad1df6977745f52f4 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:12:20 -0700 Subject: [PATCH 220/610] fix(delegation): positive-proof ownership for the post-turn drain Extends the salvaged session_key filter with the same fail-closed, compression-chain-aware ownership gate the poller uses (#55578): - drain_notifications() accepts an owns_event callback; when provided, an async-delegation event is consumed ONLY on positive proof of ownership, and a broken callback re-queues (never leaks). Bare key equality remains for single-session callers (CLI); no filter remains legacy behavior. - The TUI post-turn drain passes _session_owns_notification_event, so it can't adopt another session's (or an orphan's) delegation payload, while a post-compression session still claims its own pre-compression dispatches - the gap bare key equality left open. --- tests/tools/test_process_registry.py | 72 ++++++++++++++++++++++++++++ tools/process_registry.py | 47 ++++++++++++------ tui_gateway/server.py | 6 +++ 3 files changed, 111 insertions(+), 14 deletions(-) diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index 5dac30f2a1e6..deee514370f9 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -1449,6 +1449,78 @@ def test_drain_notifications_no_filter_passes_all_async_delegation(): process_registry.completion_queue.get_nowait() +def test_drain_notifications_owns_event_callback_beats_key_equality(): + """The positive-proof ownership callback consumes ONLY approved events — + including across a compression rotation where bare key equality would + wrongly re-queue the session's own pre-compression dispatch (#55578).""" + from tools.process_registry import process_registry + + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + try: + # Pre-compression dispatch: event carries the OLD key. + process_registry.completion_queue.put({ + "type": "async_delegation", + "delegation_id": "deleg_precompress", + "session_key": "old_parent_key", + "goal": "task", "status": "completed", "summary": "mine", + "api_calls": 1, "duration_seconds": 0.1, + }) + # Foreign event that plain key equality would also reject. + process_registry.completion_queue.put({ + "type": "async_delegation", + "delegation_id": "deleg_foreign", + "session_key": "someone_else", + "goal": "task", "status": "completed", "summary": "not mine", + "api_calls": 1, "duration_seconds": 0.1, + }) + + # Chain-aware ownership: this session's lineage includes old_parent_key. + lineage = {"old_parent_key", "new_child_key"} + results = process_registry.drain_notifications( + session_key="new_child_key", + owns_event=lambda e: e.get("session_key") in lineage, + ) + assert [r[0]["delegation_id"] for r in results] == ["deleg_precompress"] + + # The foreign event was re-queued, not consumed. + leftover = process_registry.completion_queue.get_nowait() + assert leftover["delegation_id"] == "deleg_foreign" + finally: + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + +def test_drain_notifications_owns_event_callback_fails_closed(): + """A broken ownership callback must re-queue (never leak) the event.""" + from tools.process_registry import process_registry + + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + try: + process_registry.completion_queue.put({ + "type": "async_delegation", + "delegation_id": "deleg_x", + "session_key": "k", + "goal": "task", "status": "completed", "summary": "s", + "api_calls": 1, "duration_seconds": 0.1, + }) + + def broken(_evt): + raise RuntimeError("ownership check exploded") + + results = process_registry.drain_notifications( + session_key="k", owns_event=broken + ) + assert results == [] + assert process_registry.completion_queue.get_nowait()["delegation_id"] == "deleg_x" + finally: + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + # --------------------------------------------------------------------------- # _terminate_host_pid — cross-platform process-tree termination # --------------------------------------------------------------------------- diff --git a/tools/process_registry.py b/tools/process_registry.py index ae21d94e5c08..0faa80ba6e22 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -1149,7 +1149,7 @@ class ProcessRegistry: return session_id in self._completion_consumed or session_id in self._poll_observed def drain_notifications( - self, session_key: str = "", + self, session_key: str = "", owns_event=None, ) -> "list[tuple[dict, str]]": """Pop all pending notification events and return formatted pairs. @@ -1157,12 +1157,21 @@ class ProcessRegistry: Skips completion events the agent already consumed via wait/log or observed inline via poll() (see ``_drain_should_skip``). - When ``session_key`` is non-empty, async-delegation events whose - ``session_key`` does NOT match are re-queued rather than consumed, - so they remain available for the correct session. This prevents - background delegation results from being delivered to the wrong - session when the user switches between gateway sessions while a - subagent is running (issue #58684). + Async-delegation events carry a conversation payload, so draining one + into the wrong session is a cross-chat leak (#58684, #55578). Two + filter modes, strongest wins: + + - ``owns_event(evt) -> bool``: positive-proof ownership callback. + When provided, an async-delegation event is consumed ONLY if the + callback returns True; everything else is re-queued for its owner. + The TUI passes its compression-chain-aware ownership check here so + a post-compression session still claims its own pre-compression + dispatches. + - ``session_key``: plain key equality (CLI and other single-session + callers). Non-matching async-delegation events are re-queued. + + With neither set, all events are consumed (legacy single-session + behavior, backward compatible). """ results: "list[tuple[dict, str]]" = [] requeue: "list[dict]" = [] @@ -1174,13 +1183,23 @@ class ProcessRegistry: _evt_sid = evt.get("session_id", "") if evt.get("type") == "completion" and self._drain_should_skip(_evt_sid): continue - # Filter async-delegation events by the caller's session_key so - # they are not delivered to the wrong session/thread (#58684). - if session_key and evt.get("type") == "async_delegation": - evt_session_key = evt.get("session_key", "") or "" - if evt_session_key != session_key: - requeue.append(evt) - continue + # Filter async-delegation events so they are not delivered to the + # wrong session/thread (#58684). Positive-proof callback beats + # bare key equality when the caller can provide one. + if evt.get("type") == "async_delegation": + if owns_event is not None: + try: + owned = bool(owns_event(evt)) + except Exception: + owned = False # fail closed — never leak on a broken check + if not owned: + requeue.append(evt) + continue + elif session_key: + evt_session_key = evt.get("session_key", "") or "" + if evt_session_key != session_key: + requeue.append(evt) + continue text = format_process_notification(evt) if text: results.append((evt, text)) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index c640a4d5a6a6..e8e01e97ad67 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -9337,8 +9337,14 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: try: from tools.process_registry import process_registry + # Positive-proof ownership (compression-chain aware) — the same + # fail-closed gate the poller uses, so the post-turn drain can't + # adopt another session's (or an orphan's) delegation payload, + # while a post-compression session still claims its own + # pre-compression dispatches (#55578). for _evt, synth in process_registry.drain_notifications( session_key=session.get("session_key", ""), + owns_event=lambda e: _session_owns_notification_event(sid, session, e), ): with session["history_lock"]: if session.get("running"): From c0fbee990e90656fc0fe49d5c237b97958499acc Mon Sep 17 00:00:00 2001 From: kyssta-exe <218078013+kyssta-exe@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:44:23 +0000 Subject: [PATCH 221/610] fix(desktop): register /compress command in TUI gateway dispatch so Desktop can invoke it --- tui_gateway/server.py | 66 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e8e01e97ad67..29bf3315ef55 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -12196,6 +12196,72 @@ def _(rid, params: dict) -> dict: }, ) + if name in {"compress", "compact"}: + if not session: + return _err(rid, 4001, "no active session to compress") + if session.get("running"): + return _err( + rid, 4009, "session busy — /interrupt the current turn before /compress" + ) + sid = params.get("session_id", "") + try: + from agent.manual_compression_feedback import summarize_manual_compression + from agent.model_metadata import estimate_request_tokens_rough + + with session["history_lock"]: + before_messages = list(session.get("history", [])) + history_version = int(session.get("history_version", 0)) + before_count = len(before_messages) + _agent = session["agent"] + _sys_prompt = getattr(_agent, "_cached_system_prompt", "") or "" + _tools = getattr(_agent, "tools", None) or None + before_tokens = ( + estimate_request_tokens_rough( + before_messages, system_prompt=_sys_prompt, tools=_tools + ) + if before_count + else 0 + ) + removed, usage = _compress_session_history( + session, + arg.strip() or None, + approx_tokens=before_tokens, + before_messages=before_messages, + history_version=history_version, + ) + with session["history_lock"]: + after_messages = list(session.get("history", [])) + after_count = len(after_messages) + _sys_prompt_after = ( + getattr(_agent, "_cached_system_prompt", "") or _sys_prompt + ) + _tools_after = getattr(_agent, "tools", None) or _tools + after_tokens = ( + estimate_request_tokens_rough( + after_messages, + system_prompt=_sys_prompt_after, + tools=_tools_after, + ) + if after_count + else 0 + ) + _sync_session_key_after_compress(sid, session) + summary = summarize_manual_compression( + before_messages, after_messages, before_tokens, after_tokens + ) + _emit("session.info", sid, _session_info(session.get("agent"), session)) + return _ok( + rid, + { + "type": "exec", + "output": "\n".join( + filter(None, [summary["headline"], summary["token_line"], summary.get("note")]) + ), + }, + ) + except Exception as exc: + return _err(rid, 5009, f"compress failed: {exc}") + return _err(rid, 4018, f"not a quick/plugin/skill command: {name}") From 7e3986ae686977dd4a4dc6bb3080f0ce2fadb588 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:33:19 -0700 Subject: [PATCH 222/610] fix(tui): route /compress and /compact past the slash worker to command.dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from #60834 (same author) — pending-input routing so clients that fail the slash.exec->dispatch fallback still reach the new compress handler. --- tui_gateway/server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 29bf3315ef55..8357de83daed 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -11611,6 +11611,8 @@ _PENDING_INPUT_COMMANDS: frozenset[str] = frozenset( "moa", "undo", "learn", + "compress", + "compact", } ) From e10c8eba00b05a1d1c1b93ebbbe5b0ddb38c946d Mon Sep 17 00:00:00 2001 From: kyssta-exe <218078013+kyssta-exe@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:46:05 +0000 Subject: [PATCH 223/610] fix(whatsapp): use windows_detach_popen_kwargs to prevent console window flash on Windows --- plugins/platforms/whatsapp/adapter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index e79b2d8fe3df..cd44c548418b 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -27,6 +27,7 @@ _IS_WINDOWS = platform.system() == "Windows" from pathlib import Path from typing import Dict, Optional, Any +from hermes_cli._subprocess_compat import windows_detach_popen_kwargs from hermes_constants import ( find_node_executable, get_hermes_dir, @@ -648,8 +649,8 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): ], stdout=bridge_log_fh, stderr=bridge_log_fh, - start_new_session=True, env=bridge_env, + **windows_detach_popen_kwargs(), ) _write_bridge_pidfile(self._session_path, self._bridge_process.pid) From beafc55c7d99d87d6165665a19339dea774dec8b Mon Sep 17 00:00:00 2001 From: Jakub Wolniewicz Date: Mon, 6 Jul 2026 19:47:54 +0200 Subject: [PATCH 224/610] fix(desktop): dismiss stale prompt overlays --- .../src/components/prompt-overlays.test.tsx | 67 +++++++++++++++++++ .../src/components/prompt-overlays.tsx | 13 ++++ apps/desktop/src/lib/gateway-rpc.test.ts | 14 +++- apps/desktop/src/lib/gateway-rpc.ts | 7 ++ 4 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/components/prompt-overlays.test.tsx diff --git a/apps/desktop/src/components/prompt-overlays.test.tsx b/apps/desktop/src/components/prompt-overlays.test.tsx new file mode 100644 index 000000000000..7b8cb2148f5e --- /dev/null +++ b/apps/desktop/src/components/prompt-overlays.test.tsx @@ -0,0 +1,67 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n' +import { $gateway } from '@/store/gateway' +import { notifyError } from '@/store/notifications' +import { $secretRequest, $sudoRequest, clearAllPrompts, setSecretRequest, setSudoRequest } from '@/store/prompts' +import { $activeSessionId } from '@/store/session' + +import { PromptOverlays } from './prompt-overlays' + +vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() })) +vi.mock('@/store/notifications', () => ({ notifyError: vi.fn() })) + +function renderPrompts() { + render( + + + + ) +} + +afterEach(() => { + cleanup() + clearAllPrompts() + $activeSessionId.set(null) + $gateway.set(null) + vi.clearAllMocks() +}) + +describe('PromptOverlays', () => { + it('dismisses a stale sudo dialog when the gateway no longer has the password request', async () => { + const request = vi.fn().mockRejectedValue(new Error('no pending password request')) + + $activeSessionId.set('s1') + $gateway.set({ request } as never) + setSudoRequest({ requestId: 'sudo-1', sessionId: 's1' }) + + renderPrompts() + + expect(screen.getByText('Administrator password')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => expect($sudoRequest.get()).toBeNull()) + expect(request).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' }) + expect(notifyError).not.toHaveBeenCalled() + }) + + it('dismisses a stale secret dialog when the gateway no longer has the value request', async () => { + const request = vi.fn().mockRejectedValue(new Error('no pending value request')) + + $activeSessionId.set('s1') + $gateway.set({ request } as never) + setSecretRequest({ envVar: 'TEST_SECRET', prompt: 'Paste a secret', requestId: 'secret-1', sessionId: 's1' }) + + renderPrompts() + + expect(screen.getByText('TEST_SECRET')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => expect($secretRequest.get()).toBeNull()) + expect(request).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' }) + expect(notifyError).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/components/prompt-overlays.tsx b/apps/desktop/src/components/prompt-overlays.tsx index a43303e1ced3..cf56d62e85ad 100644 --- a/apps/desktop/src/components/prompt-overlays.tsx +++ b/apps/desktop/src/components/prompt-overlays.tsx @@ -15,6 +15,7 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { useI18n } from '@/i18n' +import { isMissingPendingPromptRequest } from '@/lib/gateway-rpc' import { triggerHaptic } from '@/lib/haptics' import { KeyRound, Loader2, Lock } from '@/lib/icons' import { $gateway } from '@/store/gateway' @@ -69,6 +70,12 @@ function SudoDialog() { triggerHaptic('submit') clearSudoRequest(request.sessionId, request.requestId) } catch (error) { + if (isMissingPendingPromptRequest(error, 'password')) { + clearSudoRequest(request.sessionId, request.requestId) + + return + } + notifyError(error, copy.sudoSendFailed) setSubmitting(false) } @@ -165,6 +172,12 @@ function SecretDialog() { triggerHaptic('submit') clearSecretRequest(request.sessionId, request.requestId) } catch (error) { + if (isMissingPendingPromptRequest(error, 'value')) { + clearSecretRequest(request.sessionId, request.requestId) + + return + } + notifyError(error, copy.secretSendFailed) setSubmitting(false) } diff --git a/apps/desktop/src/lib/gateway-rpc.test.ts b/apps/desktop/src/lib/gateway-rpc.test.ts index 6da30b87b765..6c84c12ecaa8 100644 --- a/apps/desktop/src/lib/gateway-rpc.test.ts +++ b/apps/desktop/src/lib/gateway-rpc.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { isMissingRpcMethod } from './gateway-rpc' +import { isMissingPendingPromptRequest, isMissingRpcMethod } from './gateway-rpc' describe('isMissingRpcMethod', () => { it('detects JSON-RPC method-not-found errors', () => { @@ -14,3 +14,15 @@ describe('isMissingRpcMethod', () => { expect(isMissingRpcMethod(new Error('no such project'))).toBe(false) }) }) + +describe('isMissingPendingPromptRequest', () => { + it('detects stale prompt response errors from the gateway', () => { + expect(isMissingPendingPromptRequest(new Error('no pending password request'), 'password')).toBe(true) + expect(isMissingPendingPromptRequest(new Error('RPC failed: no pending value request'), 'value')).toBe(true) + }) + + it('ignores unrelated gateway failures', () => { + expect(isMissingPendingPromptRequest(new Error('gateway not connected'), 'password')).toBe(false) + expect(isMissingPendingPromptRequest(new Error('no pending value request'), 'password')).toBe(false) + }) +}) diff --git a/apps/desktop/src/lib/gateway-rpc.ts b/apps/desktop/src/lib/gateway-rpc.ts index a209aefbd001..6cf298402f32 100644 --- a/apps/desktop/src/lib/gateway-rpc.ts +++ b/apps/desktop/src/lib/gateway-rpc.ts @@ -4,3 +4,10 @@ export function isMissingRpcMethod(error: unknown): boolean { return /method not found|-32601|unknown method|no such method/i.test(message) } + +/** True when a prompt response raced a backend-side timeout / completion. */ +export function isMissingPendingPromptRequest(error: unknown, key: string): boolean { + const message = error instanceof Error ? error.message : String(error) + + return message.toLowerCase().includes(`no pending ${key.toLowerCase()} request`) +} From efb226b586a573da8c759e54b91d998bc81d96c8 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:12:54 +0200 Subject: [PATCH 225/610] fix(cli): preserve chat -q answer by gating exit-summary screen clear (#53009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In single-query (-q) mode, the assistant's final answer was printed and then immediately erased by _print_exit_summary() — which unconditionally called _clear_terminal_on_exit() (ESC[3J ESC[2J ESC[H]). The answer was present in the session store but invisible in the terminal. The clear is only needed for interactive TUI teardown (#38928) where prompt_toolkit chrome must be cleaned up. Add a clear_screen parameter to _print_exit_summary() (default True, preserving interactive behavior) and pass False from the single-query call site so the answer stays visible above the exit summary. Regression tests cover: - clear_screen=True (default) calls _clear_terminal_on_exit() - clear_screen=False skips the clear - Single-query -q path passes False end-to-end - Interactive path still clears (preserving #38928) --- cli.py | 36 ++++--- tests/cli/test_chat_q_exit_clear.py | 149 ++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 tests/cli/test_chat_q_exit_clear.py diff --git a/cli.py b/cli.py index 4f7f0e831142..63b0240ec088 100644 --- a/cli.py +++ b/cli.py @@ -12745,19 +12745,27 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): except (Exception, KeyboardInterrupt) as e: logger.debug("Could not persist active CLI session before close: %s", e) - def _print_exit_summary(self): - """Print session resume info on exit, similar to Claude Code.""" - # Clear the screen + scrollback before printing the summary so the - # live bottom chrome (status bar, input box, separator rules) and the - # rest of the session transcript don't get stranded above the exit - # summary (#38252). By this point app.run() has returned and - # prompt_toolkit has restored terminal modes, so writing raw escapes - # to stdout is safe. ESC[3J clears scrollback, ESC[2J clears the - # visible screen, ESC[H homes the cursor — so the summary prints at a - # clean top-left. Falls back to the platform clear command if stdout - # isn't a TTY-capable stream. Honors NO_COLOR/dumb terminals by - # skipping silently when there's no real console. - self._clear_terminal_on_exit() + def _print_exit_summary(self, clear_screen: bool = True): + """Print session resume info on exit, similar to Claude Code. + + Args: + clear_screen: When True (default), clear the terminal screen and + scrollback before printing the summary. This is appropriate for + interactive TUI teardown (#38252). Single-query (-q) mode should + pass False to preserve the printed answer (#53009). + """ + if clear_screen: + # Clear the screen + scrollback before printing the summary so the + # live bottom chrome (status bar, input box, separator rules) and the + # rest of the session transcript don't get stranded above the exit + # summary (#38252). By this point app.run() has returned and + # prompt_toolkit has restored terminal modes, so writing raw escapes + # to stdout is safe. ESC[3J clears scrollback, ESC[2J clears the + # visible screen, ESC[H homes the cursor — so the summary prints at a + # clean top-left. Falls back to the platform clear command if stdout + # isn't a TTY-capable stream. Honors NO_COLOR/dumb terminals by + # skipping silently when there's no real console. + self._clear_terminal_on_exit() print() msg_count = len(self.conversation_history) if msg_count > 0: @@ -16171,7 +16179,7 @@ def main( # banner, doesn't depend on the welcome banner being shown. cli._show_security_advisories() cli.chat(query, images=single_query_images or None) - cli._print_exit_summary() + cli._print_exit_summary(clear_screen=False) finally: _finalize_single_query(cli) return diff --git a/tests/cli/test_chat_q_exit_clear.py b/tests/cli/test_chat_q_exit_clear.py new file mode 100644 index 000000000000..9382123d0a03 --- /dev/null +++ b/tests/cli/test_chat_q_exit_clear.py @@ -0,0 +1,149 @@ +"""Regression tests for #53009: chat -q final response erased by exit-summary clear.""" + +from types import SimpleNamespace + +import pytest + +import cli as cli_mod + + +# ── A3.1 Test-First: verify _clear_terminal_on_exit gating ────────────────── + +def test_print_exit_summary_clears_screen_by_default(monkeypatch): + """Default behavior: _print_exit_summary() calls _clear_terminal_on_exit().""" + calls = [] + + class FakeCLI: + conversation_history = [] + session_start = None + + def _clear_terminal_on_exit(self): + calls.append("clear") + + monkeypatch.setattr(cli_mod, "datetime", SimpleNamespace( + now=lambda: SimpleNamespace( + __sub__=lambda self, other: SimpleNamespace( + total_seconds=lambda: 0 + ) + ) + )) + + fake = FakeCLI() + cli_mod.HermesCLI._print_exit_summary(fake) # default clear_screen=True + + assert "clear" in calls, "_clear_terminal_on_exit should be called by default" + + +def test_print_exit_summary_skips_clear_when_clear_screen_false(monkeypatch): + """With clear_screen=False, _print_exit_summary() does NOT clear.""" + calls = [] + + class FakeCLI: + conversation_history = [] + session_start = None + + def _clear_terminal_on_exit(self): + calls.append("clear") + + monkeypatch.setattr(cli_mod, "datetime", SimpleNamespace( + now=lambda: SimpleNamespace( + __sub__=lambda self, other: SimpleNamespace( + total_seconds=lambda: 0 + ) + ) + )) + + fake = FakeCLI() + cli_mod.HermesCLI._print_exit_summary(fake, clear_screen=False) + + assert "clear" not in calls, ( + "_clear_terminal_on_exit should NOT be called when clear_screen=False" + ) + + +# ── Production-path test: single-query -q path skips the clear ────────────── + +def test_single_query_main_skips_clear_on_exit_summary(monkeypatch): + """The single-query (-q) path calls _print_exit_summary without clearing.""" + calls = [] + clear_calls = [] + + class FakeCLI: + def __init__(self, **_kwargs): + self.console = SimpleNamespace(print=lambda *_a, **_kw: calls.append("query-label")) + self.session_id = "sq-test" + self.agent = SimpleNamespace( + session_id="sq-test", + platform="cli", + ) + + def _claim_active_session(self, surface, *, stderr=False): + calls.append(("claim", surface, stderr)) + return True + + def _show_security_advisories(self): + calls.append("advisories") + + def chat(self, query, images=None): + calls.append(("chat", query, images)) + return "done" + + def _print_exit_summary(self, clear_screen=True): + calls.append(("summary", clear_screen)) + if clear_screen: + clear_calls.append("CLEARED") # should NOT happen + + monkeypatch.setattr(cli_mod, "HermesCLI", FakeCLI) + monkeypatch.setattr(cli_mod.atexit, "register", lambda *_a, **_kw: None) + monkeypatch.setattr( + cli_mod, + "_finalize_single_query", + lambda fake_cli: calls.append(("finalize", fake_cli.session_id)), + ) + + cli_mod.main(query="hello", quiet=False, toolsets="terminal") + + assert calls == [ + ("claim", "cli", False), + "query-label", + "advisories", + ("chat", "hello", None), + ("summary", False), # <-- clear_screen=False for single-query + ("finalize", "sq-test"), + ] + assert len(clear_calls) == 0, ( + "_clear_terminal_on_exit must NOT be called in single-query mode" + ) + + +# ── Verify interactive mode still clears ──────────────────────────────────── + +def test_print_exit_summary_still_clears_in_interactive_path(monkeypatch): + """Interactive mode should still clear the screen (preserving #38928).""" + from datetime import datetime as real_datetime + + calls = [] + + class FakeCLI: + conversation_history = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + session_start = real_datetime(2026, 1, 1, 12, 0, 0) + session_id = "test-session" + _session_db = None + agent = None + + def _clear_terminal_on_exit(self): + calls.append("clear") + + monkeypatch.setattr(cli_mod, "datetime", SimpleNamespace( + now=lambda: real_datetime(2026, 1, 1, 12, 1, 0) # 1 min elapsed + )) + + fake = FakeCLI() + cli_mod.HermesCLI._print_exit_summary(fake) # default clear_screen=True + + assert "clear" in calls, ( + "Interactive mode should still clear the screen (regression test for #38928)" + ) From 58e1647b498291bdd28563714513292406d6a876 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:40:34 -0700 Subject: [PATCH 226/610] test(cli): update FakeCLI._print_exit_summary for new clear_screen kwarg --- tests/cli/test_single_query_session_finalize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cli/test_single_query_session_finalize.py b/tests/cli/test_single_query_session_finalize.py index 20a75acd37b6..3041c03dbfe1 100644 --- a/tests/cli/test_single_query_session_finalize.py +++ b/tests/cli/test_single_query_session_finalize.py @@ -177,7 +177,7 @@ def test_human_single_query_main_finalizes_after_query(monkeypatch): calls.append(("chat", query, images)) return "done" - def _print_exit_summary(self): + def _print_exit_summary(self, clear_screen=True): calls.append("summary") monkeypatch.setattr(cli_mod, "HermesCLI", FakeCLI) From 63c4100fe6184edc42d3d8673cfc042f4ea42013 Mon Sep 17 00:00:00 2001 From: loongfay Date: Tue, 16 Jun 2026 22:41:40 +0800 Subject: [PATCH 227/610] perf(yuanbao): bounded-concurrency inbound media resolve --- gateway/platforms/yuanbao.py | 212 +++++++++++++++++++++++------ tests/test_yuanbao_pipeline.py | 242 +++++++++++++++++++++++++++++++++ 2 files changed, 409 insertions(+), 45 deletions(-) diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py index 23c0d4d45f62..4657afd52190 100644 --- a/gateway/platforms/yuanbao.py +++ b/gateway/platforms/yuanbao.py @@ -179,6 +179,16 @@ OBSERVED_MEDIA_BACKFILL_LOOKBACK = 50 # Max number of resource references to resolve per inbound turn OBSERVED_MEDIA_BACKFILL_MAX_RESOLVE_PER_TURN = 12 +# Bounded concurrency for inbound media resolve/download. +# - 1 = sequential (legacy behavior, safe rollback knob) +# - 6 = default; aligns with the per-origin HTTP/1.1 ceiling browsers use, +# balances first-token latency vs. backend pressure +# - 12 = upper clamp; matches OBSERVED_MEDIA_BACKFILL_MAX_RESOLVE_PER_TURN +# Configured via config.yaml: platforms.yuanbao.extra.media_resolve_concurrency. +_DEFAULT_RESOLVE_CONCURRENCY = 6 +_MIN_RESOLVE_CONCURRENCY = 1 +_MAX_RESOLVE_CONCURRENCY = 12 + class MarkdownProcessor: """Encapsulates all Markdown-related utilities for the Yuanbao platform. @@ -2813,45 +2823,94 @@ class MediaResolveMiddleware(InboundMiddleware): Yuanbao COS hostnames resolve to private IPs, tripping the SSRF guard in vision_tools. We download ourselves and return local cache paths. - """ - media_urls: List[str] = [] - media_types: List[str] = [] + Resolution runs with bounded concurrency + (``adapter.media_resolve_concurrency``); see :meth:`_resolve_ybres_refs` + for the same order-preserving / exception-isolated contract. + """ + # Pre-filter resolvable refs, preserving input order. + active: List[Tuple[str, str, str, str]] = [] for ref in media_refs: kind = str(ref.get("kind") or "").strip().lower() url = str(ref.get("url") or "").strip() filename = str(ref.get("name") or "").strip() if kind not in _RESOLVABLE_MEDIA_KINDS or not url: continue - - # Extract resourceId from the placeholder URL for cache dedup. rid = ExtractContentMiddleware._parse_resource_id(url) - if rid and cls._append_cached_resource(adapter, rid, media_urls, media_types): - continue + active.append((kind, url, filename, rid)) - try: - fetch_url = await cls._resolve_download_url(adapter, url) - except Exception as exc: - logger.warning( - "[%s] inbound media resolve failed: kind=%s url=%s err=%s", - adapter.name, kind, url, exc, + if not active: + return [], [] + + semaphore = asyncio.Semaphore(adapter.media_resolve_concurrency) + + async def _resolve_one( + kind: str, url: str, filename: str, rid: str, + ) -> Optional[Tuple[str, str]]: + # Cache dedup first — avoids the RPC + download entirely on a hit. + if rid: + hit = cls._get_cached_resource(rid) + if hit is not None: + logger.debug( + "[%s] resource cache hit: rid=%s path=%s", + adapter.name, rid, hit[0], + ) + return hit + async with semaphore: + try: + fetch_url = await cls._resolve_download_url(adapter, url) + except Exception as exc: + logger.warning( + "[%s] inbound media resolve failed: kind=%s url=%s err=%s", + adapter.name, kind, url, exc, + ) + return None + return await cls._download_and_cache( + adapter, + fetch_url=fetch_url, + kind=kind, + file_name=filename or None, + log_tag=f"placeholder_url={url[:80]}", + resource_id=rid, ) - continue - cached = await cls._download_and_cache( - adapter, - fetch_url=fetch_url, - kind=kind, - file_name=filename or None, - log_tag=f"placeholder_url={url[:80]}", - resource_id=rid, - ) - if cached is None: + _t0 = time.monotonic() + results = await asyncio.gather( + *(_resolve_one(kind, url, filename, rid) + for kind, url, filename, rid in active), + return_exceptions=True, + ) + _elapsed_ms = int((time.monotonic() - _t0) * 1000) + + media_urls: List[str] = [] + media_types: List[str] = [] + _failed = 0 + for (kind, url, _filename, _rid), result in zip(active, results): + if isinstance(result, BaseException): + logger.warning( + "[%s] inbound media resolve crashed: kind=%s url=%s err=%s", + adapter.name, kind, url[:80], result, + ) + _failed += 1 continue - local_path, mime = cached + if result is None: + _failed += 1 + continue + local_path, mime = result media_urls.append(local_path) media_types.append(mime) + # Batch summary: keep fields stable for offline aggregation + # (concurrency vs elapsed_ms is the core knob-tuning view). + logger.info( + "[%s] media resolve batch: scope=media concurrency=%d total=%d ok=%d failed=%d elapsed_ms=%d", + adapter.name, + adapter.media_resolve_concurrency, + len(active), + len(media_urls), + _failed, + _elapsed_ms, + ) return media_urls, media_types @classmethod @@ -2862,36 +2921,86 @@ class MediaResolveMiddleware(InboundMiddleware): *, log_prefix: str, ) -> Tuple[List[str], List[str]]: - """Resolve a list of ``(rid, kind, filename)`` ybres tuples to local paths. + """Resolve ``(rid, kind, filename)`` ybres tuples to local paths. + + Runs with bounded concurrency (``adapter.media_resolve_concurrency``) + so cold-start turns with many anchors don't pay ``N × (RPC + download)`` + sequentially. Output order matches input; per-rid failures are isolated. """ + # Pre-filter resolvable kinds, preserving input order. + active: List[Tuple[str, str, str]] = [ + (rid, kind, filename) + for rid, kind, filename in refs + if kind in _RESOLVABLE_MEDIA_KINDS + ] + if not active: + return [], [] + + semaphore = asyncio.Semaphore(adapter.media_resolve_concurrency) + + async def _resolve_one(rid: str, kind: str, filename: str) -> Optional[Tuple[str, str]]: + # Cache dedup first — avoids the RPC + download entirely on a hit. + hit = cls._get_cached_resource(rid) + if hit is not None: + logger.debug( + "[%s] resource cache hit: rid=%s path=%s", + adapter.name, rid, hit[0], + ) + return hit + async with semaphore: + try: + fresh_url = await cls._fetch_resource_url(adapter, rid) + except Exception as exc: + logger.warning( + "[%s] %s resolve failed: rid=%s kind=%s err=%s", + adapter.name, log_prefix, rid, kind, exc, + ) + return None + return await cls._download_and_cache( + adapter, + fetch_url=fresh_url, + kind=kind, + file_name=filename or None, + log_tag=f"{log_prefix} rid={rid}", + resource_id=rid, + ) + + # return_exceptions=True isolates per-coroutine failures. + _t0 = time.monotonic() + results = await asyncio.gather( + *(_resolve_one(rid, kind, filename) for rid, kind, filename in active), + return_exceptions=True, + ) + _elapsed_ms = int((time.monotonic() - _t0) * 1000) + media_paths: List[str] = [] mimes: List[str] = [] - for rid, kind, filename in refs: - if kind not in _RESOLVABLE_MEDIA_KINDS: - continue - if cls._append_cached_resource(adapter, rid, media_paths, mimes): - continue - try: - fresh_url = await cls._fetch_resource_url(adapter, rid) - except Exception as exc: + _failed = 0 + for (rid, kind, _filename), result in zip(active, results): + if isinstance(result, BaseException): logger.warning( - "[%s] %s resolve failed: rid=%s kind=%s err=%s", - adapter.name, log_prefix, rid, kind, exc, + "[%s] %s resolve crashed: rid=%s kind=%s err=%s", + adapter.name, log_prefix, rid, kind, result, ) + _failed += 1 continue - cached = await cls._download_and_cache( - adapter, - fetch_url=fresh_url, - kind=kind, - file_name=filename or None, - log_tag=f"{log_prefix} rid={rid}", - resource_id=rid, - ) - if cached is None: + if result is None: + _failed += 1 continue - path, mime = cached + path, mime = result media_paths.append(path) mimes.append(mime) + + # Batch summary: stable fields for offline aggregation. + logger.info( + "[%s] media resolve batch: scope=ybres concurrency=%d total=%d ok=%d failed=%d elapsed_ms=%d", + adapter.name, + adapter.media_resolve_concurrency, + len(active), + len(media_paths), + _failed, + _elapsed_ms, + ) return media_paths, mimes @classmethod @@ -5071,6 +5180,19 @@ class YuanbaoAdapter(BasePlatformAdapter): self._api_domain: str = (_extra.get("api_domain") or DEFAULT_API_DOMAIN).rstrip("/") self._route_env: str = (_extra.get("route_env") or "").strip() + # Bounded concurrency for inbound media resolve/download. + # See _DEFAULT_RESOLVE_CONCURRENCY for rationale; clamped to [min, max] + # so a misconfigured value cannot hammer the resource backend nor + # accidentally drop below sequential behavior. + try: + _raw_concurrency = int(_extra.get("media_resolve_concurrency", _DEFAULT_RESOLVE_CONCURRENCY)) + except (TypeError, ValueError): + _raw_concurrency = _DEFAULT_RESOLVE_CONCURRENCY + self.media_resolve_concurrency: int = max( + _MIN_RESOLVE_CONCURRENCY, + min(_MAX_RESOLVE_CONCURRENCY, _raw_concurrency), + ) + # Core managers (UML composition) self._connection: ConnectionManager = ConnectionManager(self) self._outbound: OutboundManager = OutboundManager(self) diff --git a/tests/test_yuanbao_pipeline.py b/tests/test_yuanbao_pipeline.py index d55dad7e007f..fa19f5ec6bcf 100644 --- a/tests/test_yuanbao_pipeline.py +++ b/tests/test_yuanbao_pipeline.py @@ -10,6 +10,7 @@ Tests cover: 6. OOP middleware ABC and class tests """ +import asyncio import sys import os import json @@ -45,6 +46,8 @@ from gateway.platforms.yuanbao import ( DispatchMiddleware, InboundPipelineBuilder, YuanbaoAdapter, + _MIN_RESOLVE_CONCURRENCY, + _MAX_RESOLVE_CONCURRENCY, ) from gateway.config import PlatformConfig @@ -1600,6 +1603,245 @@ class TestResolveMediaUrlsCacheHit: MediaResolveMiddleware._resource_cache.clear() +class TestResolveYbresRefsConcurrency: + """Bounded-concurrency contracts for ``_resolve_ybres_refs``.""" + + # ------------------------------------------------------------------ + # Bounded-concurrency contracts (issue 3 in + # yuanbao-media-pipeline-optimizations.md). These are behavior + # contracts, not implementation snapshots — they assert the + # invariants the new gather()-based path must hold, not how it's + # wired internally. + # ------------------------------------------------------------------ + + @pytest.mark.asyncio + async def test_concurrent_resolve_preserves_input_order(self): + """Order of returned (paths, mimes) must match input ``refs`` order + even when later refs finish downloading first. + """ + adapter = make_adapter(extra={"media_resolve_concurrency": 4}) + refs = [ + ("rid-A", "image", ""), + ("rid-B", "image", ""), + ("rid-C", "image", ""), + ] + + # _fetch is fast and uniform; the interesting variation is in + # _download_and_cache, where rid-A is the slowest. If results + # were assembled by completion order, rid-A would land last. + async def slow_fetch(_adapter, rid): + return f"https://fresh/{rid}" + + delays = {"rid-A": 0.06, "rid-B": 0.02, "rid-C": 0.0} + results_by_rid = { + "rid-A": ("/cache/A.jpg", "image/jpeg"), + "rid-B": ("/cache/B.jpg", "image/jpeg"), + "rid-C": ("/cache/C.jpg", "image/jpeg"), + } + + async def slow_download(_adapter, *, fetch_url, kind, file_name, log_tag, resource_id): + await asyncio.sleep(delays[resource_id]) + return results_by_rid[resource_id] + + with patch.object( + MediaResolveMiddleware, "_fetch_resource_url", + new=AsyncMock(side_effect=slow_fetch), + ), patch.object( + MediaResolveMiddleware, "_download_and_cache", + new=AsyncMock(side_effect=slow_download), + ): + paths, mimes = await MediaResolveMiddleware._resolve_ybres_refs( + adapter, refs, log_prefix="test", + ) + + assert paths == ["/cache/A.jpg", "/cache/B.jpg", "/cache/C.jpg"] + assert mimes == ["image/jpeg", "image/jpeg", "image/jpeg"] + + @pytest.mark.asyncio + async def test_concurrency_one_equivalent_to_sequential(self): + """``media_resolve_concurrency = 1`` must behave like the legacy + sequential path — at any moment at most one ``_download_and_cache`` + is in flight. + """ + adapter = make_adapter(extra={"media_resolve_concurrency": 1}) + refs = [("rid-A", "image", ""), ("rid-B", "image", ""), ("rid-C", "image", "")] + + in_flight = 0 + max_in_flight = 0 + + async def tracked_download(_adapter, *, fetch_url, kind, file_name, log_tag, resource_id): + nonlocal in_flight, max_in_flight + in_flight += 1 + max_in_flight = max(max_in_flight, in_flight) + try: + # Yield to the event loop so any concurrent coroutine + # would have a chance to also enter the critical section. + await asyncio.sleep(0.01) + return (f"/cache/{resource_id}.jpg", "image/jpeg") + finally: + in_flight -= 1 + + with patch.object( + MediaResolveMiddleware, "_fetch_resource_url", + new=AsyncMock(side_effect=lambda _a, rid: f"https://fresh/{rid}"), + ), patch.object( + MediaResolveMiddleware, "_download_and_cache", + new=AsyncMock(side_effect=tracked_download), + ): + paths, _ = await MediaResolveMiddleware._resolve_ybres_refs( + adapter, refs, log_prefix="test", + ) + + assert max_in_flight == 1 + assert paths == ["/cache/rid-A.jpg", "/cache/rid-B.jpg", "/cache/rid-C.jpg"] + + @pytest.mark.asyncio + async def test_concurrency_caps_inflight_downloads(self): + """Configured concurrency bounds the number of in-flight downloads.""" + adapter = make_adapter(extra={"media_resolve_concurrency": 2}) + refs = [(f"rid-{i}", "image", "") for i in range(6)] + + in_flight = 0 + max_in_flight = 0 + + async def tracked_download(_adapter, *, fetch_url, kind, file_name, log_tag, resource_id): + nonlocal in_flight, max_in_flight + in_flight += 1 + max_in_flight = max(max_in_flight, in_flight) + try: + await asyncio.sleep(0.01) + return (f"/cache/{resource_id}.jpg", "image/jpeg") + finally: + in_flight -= 1 + + with patch.object( + MediaResolveMiddleware, "_fetch_resource_url", + new=AsyncMock(side_effect=lambda _a, rid: f"https://fresh/{rid}"), + ), patch.object( + MediaResolveMiddleware, "_download_and_cache", + new=AsyncMock(side_effect=tracked_download), + ): + paths, _ = await MediaResolveMiddleware._resolve_ybres_refs( + adapter, refs, log_prefix="test", + ) + + assert max_in_flight == 2 + assert paths == [f"/cache/rid-{i}.jpg" for i in range(6)] + + @pytest.mark.asyncio + async def test_download_exception_isolated_to_single_ref(self): + """An exception raised inside ``_download_and_cache`` for one rid + must not poison the whole batch; surviving refs still resolve. + """ + adapter = make_adapter(extra={"media_resolve_concurrency": 4}) + refs = [ + ("rid-ok-1", "image", ""), + ("rid-boom", "image", ""), + ("rid-ok-2", "image", ""), + ] + + async def maybe_boom(_adapter, *, fetch_url, kind, file_name, log_tag, resource_id): + if resource_id == "rid-boom": + raise RuntimeError("download crashed") + return (f"/cache/{resource_id}.jpg", "image/jpeg") + + with patch.object( + MediaResolveMiddleware, "_fetch_resource_url", + new=AsyncMock(side_effect=lambda _a, rid: f"https://fresh/{rid}"), + ), patch.object( + MediaResolveMiddleware, "_download_and_cache", + new=AsyncMock(side_effect=maybe_boom), + ): + paths, mimes = await MediaResolveMiddleware._resolve_ybres_refs( + adapter, refs, log_prefix="test", + ) + + assert paths == ["/cache/rid-ok-1.jpg", "/cache/rid-ok-2.jpg"] + assert mimes == ["image/jpeg", "image/jpeg"] + + @pytest.mark.asyncio + async def test_misconfigured_concurrency_clamped(self): + """Out-of-range or non-int concurrency values are clamped, not crashed.""" + # Negative -> clamped up to MIN + adapter_low = make_adapter(extra={"media_resolve_concurrency": -3}) + assert adapter_low.media_resolve_concurrency >= _MIN_RESOLVE_CONCURRENCY + + # Huge -> clamped down to MAX + adapter_high = make_adapter(extra={"media_resolve_concurrency": 9999}) + assert adapter_high.media_resolve_concurrency <= _MAX_RESOLVE_CONCURRENCY + + # Non-int garbage -> falls back to default, doesn't raise + adapter_garbage = make_adapter(extra={"media_resolve_concurrency": "fast"}) + assert ( + _MIN_RESOLVE_CONCURRENCY + <= adapter_garbage.media_resolve_concurrency + <= _MAX_RESOLVE_CONCURRENCY + ) + + +class TestResolveMediaUrlsConcurrency: + """Bounded-concurrency contracts for ``_resolve_media_urls`` (own-turn + media). Same invariants as ``_resolve_ybres_refs`` — order preserved, + failures isolated, concurrency clamped. + """ + + @pytest.mark.asyncio + async def test_preserves_input_order(self): + adapter = make_adapter(extra={"media_resolve_concurrency": 4}) + media_refs = [ + {"kind": "image", "url": "https://hunyuan.tencent.com/api/resource/download?resourceId=rid-A", "name": ""}, + {"kind": "image", "url": "https://hunyuan.tencent.com/api/resource/download?resourceId=rid-B", "name": ""}, + {"kind": "image", "url": "https://hunyuan.tencent.com/api/resource/download?resourceId=rid-C", "name": ""}, + ] + + delays = {"rid-A": 0.05, "rid-B": 0.02, "rid-C": 0.0} + + async def slow_download(_adapter, *, fetch_url, kind, file_name, log_tag, resource_id): + await asyncio.sleep(delays[resource_id]) + return (f"/cache/{resource_id}.jpg", "image/jpeg") + + with patch.object( + MediaResolveMiddleware, "_resolve_download_url", + new=AsyncMock(side_effect=lambda _a, url: url), + ), patch.object( + MediaResolveMiddleware, "_download_and_cache", + new=AsyncMock(side_effect=slow_download), + ): + paths, mimes = await MediaResolveMiddleware._resolve_media_urls( + adapter, media_refs, + ) + + assert paths == ["/cache/rid-A.jpg", "/cache/rid-B.jpg", "/cache/rid-C.jpg"] + assert mimes == ["image/jpeg"] * 3 + + @pytest.mark.asyncio + async def test_failure_isolated(self): + adapter = make_adapter(extra={"media_resolve_concurrency": 4}) + media_refs = [ + {"kind": "image", "url": "https://x/api/resource/download?resourceId=ok", "name": ""}, + {"kind": "image", "url": "https://x/api/resource/download?resourceId=boom", "name": ""}, + ] + + async def maybe_boom(_adapter, *, fetch_url, kind, file_name, log_tag, resource_id): + if resource_id == "boom": + raise RuntimeError("download crashed") + return ("/cache/ok.jpg", "image/jpeg") + + with patch.object( + MediaResolveMiddleware, "_resolve_download_url", + new=AsyncMock(side_effect=lambda _a, url: url), + ), patch.object( + MediaResolveMiddleware, "_download_and_cache", + new=AsyncMock(side_effect=maybe_boom), + ): + paths, mimes = await MediaResolveMiddleware._resolve_media_urls( + adapter, media_refs, + ) + + assert paths == ["/cache/ok.jpg"] + assert mimes == ["image/jpeg"] + + class TestMediaResolveMiddlewareRouting: """Branch-routing tests for MediaResolveMiddleware.handle().""" From b848fcbf11dfc9d177655f8a5ba0790cd574fe79 Mon Sep 17 00:00:00 2001 From: loongfay Date: Wed, 8 Jul 2026 10:45:16 +0800 Subject: [PATCH 228/610] feat(Yuanbao) optimizes media resource processing speed: parallel download --- gateway/platforms/yuanbao.py | 48 ++++++++++++++---------------------- 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py index 4657afd52190..7145ec578773 100644 --- a/gateway/platforms/yuanbao.py +++ b/gateway/platforms/yuanbao.py @@ -2829,6 +2829,8 @@ class MediaResolveMiddleware(InboundMiddleware): for the same order-preserving / exception-isolated contract. """ # Pre-filter resolvable refs, preserving input order. + media_urls: List[str] = [] + media_types: List[str] = [] active: List[Tuple[str, str, str, str]] = [] for ref in media_refs: kind = str(ref.get("kind") or "").strip().lower() @@ -2837,25 +2839,18 @@ class MediaResolveMiddleware(InboundMiddleware): if kind not in _RESOLVABLE_MEDIA_KINDS or not url: continue rid = ExtractContentMiddleware._parse_resource_id(url) - active.append((kind, url, filename, rid)) + if rid and cls._append_cached_resource(adapter, rid, media_urls, media_types): + continue + active.append((kind, url, filename, rid or "")) if not active: - return [], [] + return media_urls, media_types semaphore = asyncio.Semaphore(adapter.media_resolve_concurrency) async def _resolve_one( kind: str, url: str, filename: str, rid: str, ) -> Optional[Tuple[str, str]]: - # Cache dedup first — avoids the RPC + download entirely on a hit. - if rid: - hit = cls._get_cached_resource(rid) - if hit is not None: - logger.debug( - "[%s] resource cache hit: rid=%s path=%s", - adapter.name, rid, hit[0], - ) - return hit async with semaphore: try: fetch_url = await cls._resolve_download_url(adapter, url) @@ -2882,8 +2877,6 @@ class MediaResolveMiddleware(InboundMiddleware): ) _elapsed_ms = int((time.monotonic() - _t0) * 1000) - media_urls: List[str] = [] - media_types: List[str] = [] _failed = 0 for (kind, url, _filename, _rid), result in zip(active, results): if isinstance(result, BaseException): @@ -2928,25 +2921,22 @@ class MediaResolveMiddleware(InboundMiddleware): sequentially. Output order matches input; per-rid failures are isolated. """ # Pre-filter resolvable kinds, preserving input order. - active: List[Tuple[str, str, str]] = [ - (rid, kind, filename) - for rid, kind, filename in refs - if kind in _RESOLVABLE_MEDIA_KINDS - ] + # Cache-hit refs are served immediately and excluded from the gather. + media_paths: List[str] = [] + mimes: List[str] = [] + active: List[Tuple[str, str, str]] = [] + for rid, kind, filename in refs: + if kind not in _RESOLVABLE_MEDIA_KINDS: + continue + if cls._append_cached_resource(adapter, rid, media_paths, mimes): + continue + active.append((rid, kind, filename)) if not active: - return [], [] + return media_paths, mimes semaphore = asyncio.Semaphore(adapter.media_resolve_concurrency) async def _resolve_one(rid: str, kind: str, filename: str) -> Optional[Tuple[str, str]]: - # Cache dedup first — avoids the RPC + download entirely on a hit. - hit = cls._get_cached_resource(rid) - if hit is not None: - logger.debug( - "[%s] resource cache hit: rid=%s path=%s", - adapter.name, rid, hit[0], - ) - return hit async with semaphore: try: fresh_url = await cls._fetch_resource_url(adapter, rid) @@ -2973,10 +2963,8 @@ class MediaResolveMiddleware(InboundMiddleware): ) _elapsed_ms = int((time.monotonic() - _t0) * 1000) - media_paths: List[str] = [] - mimes: List[str] = [] _failed = 0 - for (rid, kind, _filename), result in zip(active, results): + for (rid, kind, filename), result in zip(active, results): if isinstance(result, BaseException): logger.warning( "[%s] %s resolve crashed: rid=%s kind=%s err=%s", From d39c62409b5f00da6f7bd31dd3fb437d8c2daad6 Mon Sep 17 00:00:00 2001 From: nankingjing <1079826437@qq.com> Date: Fri, 3 Jul 2026 13:00:10 +0800 Subject: [PATCH 229/610] fix(delegate): pin async completion to spawning parent session (#57498) Background delegate_task completions only carried session_key. When multiple active sessions shared a routing peer, get_or_create_session could recover the latest ended_at IS NULL row and inject the subagent result into the wrong session. Capture parent_agent.session_id at dispatch time, include it on async-delegation completion events, and pin gateway routing via switch_session when the synthetic completion message is handled. Fixes #57498 --- gateway/run.py | 20 ++++++++++++++++++++ tests/tools/test_async_delegation.py | 2 ++ tools/async_delegation.py | 11 +++++++++++ tools/delegate_tool.py | 2 ++ 4 files changed, 35 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index 7d5293ef411f..252836ada9fe 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10633,6 +10633,21 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew session_entry = self.session_store.get_or_create_session(source) session_key = session_entry.session_key + pinned_session_id = str( + (getattr(event, "metadata", None) or {}).get("gateway_session_id") or "" + ).strip() + if pinned_session_id and pinned_session_id != session_entry.session_id: + prior_session_id = session_entry.session_id + switched = self.session_store.switch_session(session_key, pinned_session_id) + if switched is not None: + session_entry = switched + logger.info( + "Pinned async-delegation completion to spawning session %s " + "(was %s) for routing key %s (#57498)", + pinned_session_id, + prior_session_id, + session_key, + ) self._cache_session_source(session_key, source) if await asyncio.to_thread(self._is_telegram_topic_lane, source): try: @@ -15202,12 +15217,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if not adapter: return try: + metadata = {} + parent_session_id = str(evt.get("parent_session_id") or "").strip() + if parent_session_id: + metadata["gateway_session_id"] = parent_session_id synth_event = MessageEvent( text=synth_text, message_type=MessageType.TEXT, source=source, internal=True, message_id=str(evt.get("message_id") or "").strip() or None, + metadata=metadata, ) logger.info( "Watch pattern notification — injecting for %s chat=%s thread=%s", diff --git a/tests/tools/test_async_delegation.py b/tests/tools/test_async_delegation.py index 9a473394b514..7714d3c8c08a 100644 --- a/tests/tools/test_async_delegation.py +++ b/tests/tools/test_async_delegation.py @@ -99,6 +99,7 @@ def test_completion_event_lands_on_shared_queue_with_session_key(): res = ad.dispatch_async_delegation( goal="compute X", context="some context", toolsets=["web", "file"], role="leaf", model="test-model", session_key="agent:main:cli:dm:local", + parent_session_id="20260703_parent_sid", runner=runner, max_async_children=3, ) assert res["status"] == "dispatched" @@ -108,6 +109,7 @@ def test_completion_event_lands_on_shared_queue_with_session_key(): assert evt["type"] == "async_delegation" assert evt["summary"] == "the result" assert evt["session_key"] == "agent:main:cli:dm:local" + assert evt["parent_session_id"] == "20260703_parent_sid" assert evt["delegation_id"] == res["delegation_id"] diff --git a/tools/async_delegation.py b/tools/async_delegation.py index cc22a7f6e9ab..ce4b094beb4f 100644 --- a/tools/async_delegation.py +++ b/tools/async_delegation.py @@ -129,6 +129,7 @@ def dispatch_async_delegation( role: str, model: Optional[str], session_key: str, + parent_session_id: Optional[str] = None, runner: Callable[[], Dict[str, Any]], origin_ui_session_id: str = "", interrupt_fn: Optional[Callable[[], None]] = None, @@ -146,6 +147,11 @@ def dispatch_async_delegation( captured on the parent thread BEFORE dispatch, because the daemon worker thread won't carry the contextvar. Used to route the completion back to the originating session. + parent_session_id + The durable ``state.db`` session id of the parent agent that spawned + the delegation. Carried on the completion event so the gateway can + pin routing to the spawning session instead of recovering the latest + ``ended_at IS NULL`` row for the peer tuple (#57498). runner Zero-arg callable that builds + runs the child and returns the same result dict ``_run_single_child`` produces. Runs on the worker thread. @@ -174,6 +180,7 @@ def dispatch_async_delegation( "model": model, "session_key": session_key, "origin_ui_session_id": origin_ui_session_id, + "parent_session_id": parent_session_id, "status": "running", "dispatched_at": dispatched_at, "completed_at": None, @@ -285,6 +292,7 @@ def _push_completion_event( # session; empty string => CLI (single-session) path. "session_key": record.get("session_key", ""), "origin_ui_session_id": record.get("origin_ui_session_id", ""), + "parent_session_id": record.get("parent_session_id"), "goal": record.get("goal", ""), "context": record.get("context"), "toolsets": record.get("toolsets"), @@ -319,6 +327,7 @@ def dispatch_async_delegation_batch( role: str, model: Optional[str], session_key: str, + parent_session_id: Optional[str] = None, runner: Callable[[], Dict[str, Any]], origin_ui_session_id: str = "", interrupt_fn: Optional[Callable[[], None]] = None, @@ -361,6 +370,7 @@ def dispatch_async_delegation_batch( "model": model, "session_key": session_key, "origin_ui_session_id": origin_ui_session_id, + "parent_session_id": parent_session_id, "status": "running", "dispatched_at": dispatched_at, "completed_at": None, @@ -459,6 +469,7 @@ def _finalize_batch( "delegation_id": delegation_id, "session_key": event_record.get("session_key", ""), "origin_ui_session_id": event_record.get("origin_ui_session_id", ""), + "parent_session_id": event_record.get("parent_session_id"), "goal": event_record.get("goal", ""), "goals": event_record.get("goals"), "context": event_record.get("context"), diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 4179926091a3..911e25204ab6 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2815,6 +2815,7 @@ def delegate_task( _session_key = _agent_session_id except Exception: _origin_ui_session_id = "" + _parent_session_id = getattr(parent_agent, "session_id", None) _child_agents = [c for (_, _, c) in children] # Detach every child from the parent's interrupt-propagation list — the @@ -2856,6 +2857,7 @@ def delegate_task( model=creds["model"], session_key=_session_key, origin_ui_session_id=_origin_ui_session_id, + parent_session_id=_parent_session_id, runner=_batch_runner, interrupt_fn=_batch_interrupt, max_async_children=_get_max_async_children(), From 75efd73961a88a531276d0e2e1cd30d1ec18019e Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:54:53 -0700 Subject: [PATCH 230/610] fix(gateway): never resurrect ended sessions for delegation completions; /new severs in-flight delegations Completes the session-binding class on the gateway surface (#55578), matching the TUI rules: 1. Fail-closed pinning: switch_session() re-opens ended sessions, so pinning a completion to a spawning session that has since ENDED (user /new, closed rotation) would resurrect a conversation the user explicitly ended and inject into it. The injection path now checks the pinned row's ended_at first and drops the injection with a WARNING when the spawning session is dead or unknown - the result stays in the delegation records. 2. /new ends the old conversation's delegations: _handle_reset_command calls interrupt_for_session() with the expiring durable session id (matching the parent_session_id pin stamped at dispatch) plus the routing key as fallback, so a reset can't leave dangling subagents whose completions have no live owner. interrupt_for_session() gains the parent_session_id selector because a gateway chat's session_key (the platform conversation key) survives a reset while the session id rotates - key-based matching alone could never sever a gateway conversation's delegations. --- gateway/run.py | 25 ++++ gateway/slash_commands.py | 18 +++ .../test_async_delegation_session_binding.py | 130 ++++++++++++++++++ tools/async_delegation.py | 16 ++- 4 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 tests/gateway/test_async_delegation_session_binding.py diff --git a/gateway/run.py b/gateway/run.py index 252836ada9fe..e71fe26ba8ea 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10637,6 +10637,31 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew (getattr(event, "metadata", None) or {}).get("gateway_session_id") or "" ).strip() if pinned_session_id and pinned_session_id != session_entry.session_id: + # Fail closed (#55578): the spawning session may have ENDED since + # dispatch (user /new-reset, compression rotation whose parent was + # closed). switch_session() re-opens ended sessions, so pinning + # blindly would RESURRECT a conversation the user explicitly + # ended and inject into it — the same illicit-revival class as + # the ws_orphan_reap loop (#60609). A completion whose spawning + # session is dead is dropped from injection; the subagent's + # output remains in the delegation records. + pinned_row = None + try: + if self._session_db is not None: + # AsyncSessionDB already offloads to a thread. + pinned_row = await self._session_db.get_session(pinned_session_id) + except Exception: + pinned_row = None + if pinned_row is None or pinned_row.get("ended_at"): + logger.warning( + "Async-delegation completion pinned to session %s, which is " + "%s — dropping injection instead of resurrecting it " + "(#55578 fail-closed; result remains in the delegation " + "records).", + pinned_session_id, + "unknown" if pinned_row is None else "ended", + ) + return prior_session_id = session_entry.session_id switched = self.session_store.switch_session(session_key, pinned_session_id) if switched is not None: diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index a299e8e9b35d..3d7bed924c7b 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -167,6 +167,24 @@ class GatewaySlashCommandsMixin: if _qe is not None: _qe.pop(session_key, None) + # The old conversation's in-flight async delegations end WITH it + # (#55578): after the reset rotates the session id, their completions + # would have no live owner — a dangling subagent can only burn tokens + # and park an orphaned payload on the shared queue. Interrupt by the + # expiring durable session id (delegations dispatched from gateway + # chats are pinned to it via parent_session_id) and by the routing + # key as a fallback for older records. + try: + from tools.async_delegation import interrupt_for_session + + interrupt_for_session( + session_key=session_key, + parent_session_id=str(getattr(old_entry, "session_id", "") or ""), + reason="session_reset", + ) + except Exception: + pass + try: from tools.env_passthrough import clear_env_passthrough clear_env_passthrough() diff --git a/tests/gateway/test_async_delegation_session_binding.py b/tests/gateway/test_async_delegation_session_binding.py new file mode 100644 index 000000000000..c48b37e47dfa --- /dev/null +++ b/tests/gateway/test_async_delegation_session_binding.py @@ -0,0 +1,130 @@ +"""Gateway-side session binding for async delegations (#57498, #55578). + +Three invariants on the messaging-gateway surface, mirroring the TUI rules: + +1. Completions are pinned to the spawning session (contributor commit). +2. A dead/ended spawning session is never resurrected: the injection is + dropped, fail-closed (never rerouted to the peer's current session). +3. /new interrupts the old conversation's in-flight async delegations. +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import tools.async_delegation as ad + + +@pytest.fixture(autouse=True) +def _reset_async_delegation(): + ad._reset_for_tests() + yield + ad._reset_for_tests() + + +def _seed_record(delegation_id, session_key="", parent_session_id="", status="running"): + fn = MagicMock() + with ad._records_lock: + ad._records[delegation_id] = { + "delegation_id": delegation_id, + "status": status, + "session_key": session_key, + "parent_session_id": parent_session_id, + "interrupt_fn": fn, + } + return fn + + +class TestInterruptForSessionByParentId: + def test_parent_session_id_selector(self): + mine = _seed_record("d1", session_key="agent:main:telegram:dm:1", parent_session_id="sess_old") + other = _seed_record("d2", session_key="agent:main:telegram:dm:2", parent_session_id="sess_other") + n = ad.interrupt_for_session(parent_session_id="sess_old") + assert n == 1 + mine.assert_called_once() + other.assert_not_called() + + def test_reset_interrupts_by_key_and_parent(self): + """A /new reset passes both selectors — either match claims the record.""" + by_key = _seed_record("d1", session_key="agent:main:telegram:dm:1", parent_session_id="") + by_parent = _seed_record("d2", session_key="", parent_session_id="sess_old") + unrelated = _seed_record("d3", session_key="other", parent_session_id="other") + n = ad.interrupt_for_session( + session_key="agent:main:telegram:dm:1", + parent_session_id="sess_old", + reason="session_reset", + ) + assert n == 2 + by_key.assert_called_once() + by_parent.assert_called_once() + unrelated.assert_not_called() + + +class TestGatewayPinningFailsClosed: + """The gateway injection path must never resurrect an ended session.""" + + def _make_runner(self, pinned_row): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + db = MagicMock() + db.get_session = AsyncMock(return_value=pinned_row) + runner._session_db = db + + entry = MagicMock() + entry.session_key = "agent:main:telegram:dm:1" + entry.session_id = "sess_current" + runner.session_store = MagicMock() + runner.session_store.get_or_create_session.return_value = entry + runner.session_store.switch_session.return_value = entry + return runner, entry + + def _run_pinning_prefix(self, runner, pinned_session_id): + """Execute the pinning guard logic exactly as _handle_message does.""" + + async def _go(): + event = MagicMock() + event.metadata = {"gateway_session_id": pinned_session_id} + session_entry = runner.session_store.get_or_create_session(MagicMock()) + pinned = str((getattr(event, "metadata", None) or {}).get("gateway_session_id") or "").strip() + if pinned and pinned != session_entry.session_id: + pinned_row = None + try: + if runner._session_db is not None: + pinned_row = await runner._session_db.get_session(pinned) + except Exception: + pinned_row = None + if pinned_row is None or pinned_row.get("ended_at"): + return "dropped" + switched = runner.session_store.switch_session(session_entry.session_key, pinned) + if switched is not None: + return "pinned" + return "default" + + return asyncio.run(_go()) + + def test_live_spawning_session_pins(self): + runner, _ = self._make_runner({"id": "sess_old", "ended_at": None}) + assert self._run_pinning_prefix(runner, "sess_old") == "pinned" + + def test_ended_spawning_session_drops(self): + runner, _ = self._make_runner({"id": "sess_old", "ended_at": "2026-07-08T00:00:00"}) + assert self._run_pinning_prefix(runner, "sess_old") == "dropped" + runner.session_store.switch_session.assert_not_called() + + def test_unknown_spawning_session_drops(self): + runner, _ = self._make_runner(None) + assert self._run_pinning_prefix(runner, "sess_gone") == "dropped" + runner.session_store.switch_session.assert_not_called() + + +class TestResetHandlerInterruptsDelegations: + def test_reset_command_calls_interrupt_for_session(self): + """The /new handler must sever the old conversation's delegations.""" + import inspect + from gateway import slash_commands + + src = inspect.getsource(slash_commands.GatewaySlashCommandsMixin._handle_reset_command) + assert "interrupt_for_session" in src + assert "session_reset" in src diff --git a/tools/async_delegation.py b/tools/async_delegation.py index ce4b094beb4f..9b4b0e9646ac 100644 --- a/tools/async_delegation.py +++ b/tools/async_delegation.py @@ -539,6 +539,7 @@ def interrupt_all(reason: str = "shutdown") -> int: def interrupt_for_session( session_key: str = "", origin_ui_session_id: str = "", + parent_session_id: str = "", reason: str = "session_end", ) -> int: """Signal running async delegations owned by ONE session to stop. @@ -549,11 +550,17 @@ def interrupt_for_session( with no live owner, either leaking into another chat or burning tokens with no one listening (#55578). - Matches on ``origin_ui_session_id`` (the live UI session that - commissioned the work) and/or the durable ``session_key``; either - matching field claims the record. Returns how many were interrupted. + Selectors (any matching field claims the record): + - ``origin_ui_session_id``: the live TUI tab/window that commissioned it. + - ``session_key``: the durable routing key captured at dispatch. + - ``parent_session_id``: the spawning agent's durable session-db id — + the right selector for gateway chats, whose ``session_key`` (the + platform conversation key) SURVIVES a ``/new`` reset while the + session id rotates. + + Returns how many were interrupted. """ - if not session_key and not origin_ui_session_id: + if not session_key and not origin_ui_session_id and not parent_session_id: return 0 count = 0 with _records_lock: @@ -563,6 +570,7 @@ def interrupt_for_session( and ( (origin_ui_session_id and str(r.get("origin_ui_session_id") or "") == origin_ui_session_id) or (session_key and str(r.get("session_key") or "") == session_key) + or (parent_session_id and str(r.get("parent_session_id") or "") == parent_session_id) ) ] for r in targets: From 0cf2e39c411cfe99aff477b61113992f297cd6d0 Mon Sep 17 00:00:00 2001 From: Grace Date: Thu, 2 Jul 2026 23:30:07 -0600 Subject: [PATCH 231/610] feat(gateway): add webhook payload filters --- cli-config.yaml.example | 5 + gateway/platforms/webhook.py | 49 ++- gateway/platforms/webhook_filters.py | 302 ++++++++++++++++++ hermes_cli/subcommands/webhook.py | 7 + hermes_cli/web_server.py | 4 + hermes_cli/webhook.py | 8 + tests/gateway/test_webhook_adapter.py | 280 +++++++++++++++- .../test_dashboard_admin_endpoints.py | 24 ++ tests/hermes_cli/test_webhook_cli.py | 7 + .../docs/guides/webhook-github-pr-review.md | 12 +- website/docs/user-guide/messaging/webhooks.md | 68 ++++ 11 files changed, 762 insertions(+), 4 deletions(-) create mode 100644 gateway/platforms/webhook_filters.py diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 5e4bc2331771..ed7300da71d9 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -846,6 +846,11 @@ platform_toolsets: # priority_mode: prepend # priority: # - my_plugin_command +# webhook: +# extra: +# # Route scripts default to a 30 second timeout. Scripts must live under +# # the active profile's scripts directory and receive webhook JSON on stdin. +# script_timeout_seconds: 30 # # Discord-specific settings (config.yaml top-level, not under platforms:): # diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 50b59df8948b..91cdcf325fa7 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -58,6 +58,10 @@ from gateway.platforms.base import ( MessageType, SendResult, ) +from gateway.platforms.webhook_filters import ( + DEFAULT_SCRIPT_TIMEOUT_SECONDS, + WebhookRouteProcessor, +) logger = logging.getLogger(__name__) @@ -78,7 +82,6 @@ DEFAULT_PORT = 8644 _INSECURE_NO_AUTH = "INSECURE_NO_AUTH" _DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json" _RATE_WINDOW_SECONDS = 60.0 - # Hostnames/IP literals that only serve connections originating on the same # machine. Anything else is treated as a public bind for safety-rail purposes. _LOOPBACK_HOSTS = frozenset({ @@ -155,6 +158,15 @@ class WebhookAdapter(BasePlatformAdapter): self._max_body_bytes: int = int( config.extra.get("max_body_bytes", 1_048_576) ) # 1MB + self._script_timeout_seconds: int = int( + config.extra.get( + "script_timeout_seconds", + DEFAULT_SCRIPT_TIMEOUT_SECONDS, + ) + ) + self._route_processor = WebhookRouteProcessor( + script_timeout_seconds=self._script_timeout_seconds + ) # ------------------------------------------------------------------ # Lifecycle @@ -571,6 +583,41 @@ class WebhookAdapter(BasePlatformAdapter): {"status": "ignored", "event": event_type} ) + if not self._route_processor.route_filters_match( + route_config, payload, event_type, request.headers + ): + logger.info( + "[webhook] filtered event=%s route=%s", + event_type, + route_name, + ) + return web.json_response( + { + "status": "ignored", + "reason": "filter", + "route": route_name, + } + ) + + if route_config.get("script"): + keep, transformed_payload = self._route_processor.run_route_script( + route_config.get("script"), payload + ) + if not keep: + logger.info( + "[webhook] script ignored event=%s route=%s", + event_type, + route_name, + ) + return web.json_response( + { + "status": "ignored", + "reason": "script", + "route": route_name, + } + ) + payload = transformed_payload or payload + # Format prompt from template prompt_template = route_config.get("prompt", "") prompt = self._render_prompt( diff --git a/gateway/platforms/webhook_filters.py b/gateway/platforms/webhook_filters.py new file mode 100644 index 000000000000..f8db81005db1 --- /dev/null +++ b/gateway/platforms/webhook_filters.py @@ -0,0 +1,302 @@ +"""Route-local filters and script transforms for the webhook adapter.""" + +from __future__ import annotations + +import json +import logging +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +DEFAULT_SCRIPT_TIMEOUT_SECONDS = 30 +_MISSING = object() + + +def _stringify_filter_value(value: Any) -> str: + if value is _MISSING: + return "" + if isinstance(value, (dict, list)): + return json.dumps(value, sort_keys=True) + return str(value) + + +def _resolve_profile_path(path_value: Any) -> Optional[Path]: + """Resolve a user path, mapping ~/.hermes to the active profile home.""" + if not isinstance(path_value, str): + return None + raw = os.path.expandvars(path_value.strip()) + if not raw: + return None + from hermes_constants import get_hermes_home + + hermes_home = get_hermes_home() + if raw == "~/.hermes": + return hermes_home + if raw.startswith("~/.hermes/"): + return hermes_home / raw.removeprefix("~/.hermes/") + path = Path(raw).expanduser() + if path.is_absolute(): + return path + return hermes_home / path + + +def _resolve_script_path(script_value: Any) -> tuple[Optional[Path], Optional[str]]: + """Resolve a route script under HERMES_HOME/scripts.""" + if not isinstance(script_value, str) or not script_value.strip(): + return None, "script path is empty" + from hermes_constants import get_hermes_home + + scripts_root = (get_hermes_home() / "scripts").resolve() + raw_text = os.path.expandvars(script_value.strip()) + if raw_text == "~/.hermes" or raw_text.startswith("~/.hermes/"): + mapped = _resolve_profile_path(raw_text) + candidate = mapped.resolve() if mapped is not None else scripts_root + else: + raw = Path(raw_text).expanduser() + candidate = raw.resolve() if raw.is_absolute() else (scripts_root / raw).resolve() + try: + candidate.relative_to(scripts_root) + except ValueError: + return None, f"script path resolves outside {scripts_root}" + if not candidate.exists(): + return None, f"script not found: {candidate}" + if not candidate.is_file(): + return None, f"script path is not a file: {candidate}" + return candidate, None + + +def _load_filter_file_values(path_value: Any) -> list[Any]: + path = _resolve_profile_path(path_value) + if path is None: + return [] + try: + raw = path.read_text(encoding="utf-8") + except OSError as exc: + logger.warning("[webhook] filter in_file read failed for %s: %s", path, exc) + return [] + try: + data = json.loads(raw) + except json.JSONDecodeError: + return [line.strip() for line in raw.splitlines() if line.strip()] + if isinstance(data, list): + return data + if isinstance(data, dict): + return list(data.keys()) + return [data] + + +class WebhookRouteProcessor: + """Evaluate declarative filters and optional script transforms.""" + + def __init__( + self, + *, + script_timeout_seconds: int = DEFAULT_SCRIPT_TIMEOUT_SECONDS, + ) -> None: + self.script_timeout_seconds = max(1, int(script_timeout_seconds)) + + def resolve_filter_field( + self, + field: Any, + payload: dict, + event_type: str, + headers: Any, + ) -> Any: + """Resolve a dotted filter field against payload/event/headers context.""" + if not isinstance(field, str) or not field.strip(): + return _MISSING + parts = [part for part in field.strip().split(".") if part] + if not parts: + return _MISSING + header_dict = dict(headers or {}) + context = { + "payload": payload.get("payload", payload), + "event": event_type, + "event_type": event_type, + "headers": header_dict, + } + if parts[0] in context: + value: Any = context[parts[0]] + parts = parts[1:] + else: + value = payload + for part in parts: + if isinstance(value, dict): + value = value.get(part, _MISSING) + elif isinstance(value, list) and part.isdigit(): + idx = int(part) + value = value[idx] if 0 <= idx < len(value) else _MISSING + else: + return _MISSING + if value is _MISSING: + return _MISSING + return value + + def filter_matches( + self, + spec: Any, + payload: dict, + event_type: str, + headers: Any, + ) -> bool: + """Evaluate one declarative webhook filter spec.""" + if not isinstance(spec, dict): + logger.warning("[webhook] Ignoring invalid filter spec: %r", spec) + return False + + if "all" in spec: + items = spec.get("all") + return isinstance(items, list) and all( + self.filter_matches(item, payload, event_type, headers) + for item in items + ) + if "any" in spec: + items = spec.get("any") + return isinstance(items, list) and any( + self.filter_matches(item, payload, event_type, headers) + for item in items + ) + if "not" in spec: + return not self.filter_matches(spec.get("not"), payload, event_type, headers) + + value = self.resolve_filter_field( + spec.get("field"), payload, event_type, headers + ) + + if "exists" in spec: + exists = value is not _MISSING + return exists is bool(spec.get("exists")) + if spec.get("missing") is True: + return value is _MISSING + if "equals" in spec: + return value is not _MISSING and value == spec.get("equals") + if "not_equals" in spec: + return value is _MISSING or value != spec.get("not_equals") + if "contains" in spec: + needle = spec.get("contains") + if value is _MISSING: + return False + if isinstance(value, (list, tuple, set, dict)): + return needle in value + return str(needle) in _stringify_filter_value(value) + if "in" in spec: + haystack = spec.get("in") + return isinstance(haystack, list) and value in haystack + if "in_file" in spec: + return value in _load_filter_file_values(spec.get("in_file")) + if "regex" in spec: + if value is _MISSING: + return False + try: + return ( + re.search(str(spec.get("regex")), _stringify_filter_value(value)) + is not None + ) + except re.error as exc: + logger.warning("[webhook] Invalid webhook filter regex: %s", exc) + return False + + logger.warning("[webhook] Filter spec has no supported operator: %r", spec) + return False + + def route_filters_match( + self, + route_config: dict, + payload: dict, + event_type: str, + headers: Any, + ) -> bool: + filters = route_config.get("filters") or [] + if not filters: + return True + if isinstance(filters, dict): + return self.filter_matches(filters, payload, event_type, headers) + if not isinstance(filters, list): + logger.warning("[webhook] filters must be a list or object") + return False + return all( + self.filter_matches(spec, payload, event_type, headers) + for spec in filters + ) + + def run_route_script(self, script_value: Any, payload: dict) -> tuple[bool, Optional[dict]]: + """Run a route script and return (should_continue, transformed_payload).""" + path, error = _resolve_script_path(script_value) + if error or path is None: + logger.warning("[webhook] script ignored webhook: %s", error) + return False, None + + suffix = path.suffix.lower() + if suffix in {".sh", ".bash"}: + bash = shutil.which("bash") or ( + "/bin/bash" if os.path.isfile("/bin/bash") else None + ) + if bash is None: + logger.warning("[webhook] script ignored webhook: bash not found") + return False, None + argv = [bash, str(path)] + else: + argv = [sys.executable, str(path)] + + try: + from tools.environments.local import _sanitize_subprocess_env + + popen_kwargs = {"creationflags": 0x08000000} if sys.platform == "win32" else {} + result = subprocess.run( + argv, + input=json.dumps(payload), + capture_output=True, + text=True, + timeout=self.script_timeout_seconds, + cwd=str(path.parent), + env=_sanitize_subprocess_env(os.environ.copy()), + **popen_kwargs, + ) + except subprocess.TimeoutExpired: + logger.warning("[webhook] script timed out: %s", path) + return False, None + except Exception as exc: + logger.warning("[webhook] script execution failed: %s", exc) + return False, None + + stdout = (result.stdout or "").strip() + stderr = (result.stderr or "").strip() + try: + from agent.redact import redact_sensitive_text + + stdout = redact_sensitive_text(stdout) + stderr = redact_sensitive_text(stderr) + except Exception as exc: + logger.warning("[webhook] Failed to redact script output: %s", exc) + stdout = "[REDACTED - redaction failed]" + stderr = "[REDACTED - redaction failed]" + if result.returncode != 0: + logger.info( + "[webhook] script ignored webhook path=%s code=%s stderr=%s", + path.name, + result.returncode, + stderr[:200], + ) + return False, None + if not stdout or stdout == "[SILENT]": + return False, None + + try: + transformed = json.loads(stdout) + except json.JSONDecodeError: + transformed = {**payload, "script_output": stdout} + if not isinstance(transformed, dict): + logger.warning("[webhook] script stdout must be a JSON object or text") + return False, None + if ( + transformed.get("[SILENT]") is True + or transformed.get("__hermes_ignore__") is True + ): + return False, None + return True, transformed diff --git a/hermes_cli/subcommands/webhook.py b/hermes_cli/subcommands/webhook.py index cd58da35069e..38085141b069 100644 --- a/hermes_cli/subcommands/webhook.py +++ b/hermes_cli/subcommands/webhook.py @@ -55,6 +55,13 @@ def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None: "message. Zero LLM cost. Requires --deliver to be a real target " "(not 'log').", ) + wh_sub.add_argument( + "--script", + default="", + help="Filter/transform script under ~/.hermes/scripts/. The route " + "payload is passed as JSON on stdin; empty stdout, [SILENT], or a " + "nonzero exit code ignores the webhook.", + ) webhook_subparsers.add_parser( "list", aliases=["ls"], help="List all dynamic subscriptions" diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 30c86e9e90a5..69d40bc47de6 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -10932,6 +10932,7 @@ class WebhookCreate(BaseModel): description: Optional[str] = None events: List[str] = [] prompt: Optional[str] = None + script: Optional[str] = None skills: List[str] = [] deliver: str = "log" deliver_only: bool = False @@ -10948,6 +10949,7 @@ def _webhook_route_summary(name: str, route: Dict[str, Any], base_url: str) -> D "deliver": route.get("deliver", "log"), "deliver_only": bool(route.get("deliver_only")), "prompt": route.get("prompt", ""), + "script": route.get("script", ""), "skills": list(route.get("skills") or []), "created_at": route.get("created_at"), "url": f"{base_url}/webhooks/{name}", @@ -11031,6 +11033,8 @@ async def create_webhook(body: WebhookCreate): "deliver": body.deliver or "log", "created_at": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()), } + if body.script and body.script.strip(): + route["script"] = body.script.strip() if body.deliver_only: route["deliver_only"] = True if body.deliver_chat_id: diff --git a/hermes_cli/webhook.py b/hermes_cli/webhook.py index b4c9380a7682..4d090511ae82 100644 --- a/hermes_cli/webhook.py +++ b/hermes_cli/webhook.py @@ -189,6 +189,10 @@ def _cmd_subscribe(args): return route["deliver_only"] = True + script = getattr(args, "script", "") or "" + if script.strip(): + route["script"] = script.strip() + if args.deliver_chat_id: route["deliver_extra"] = {"chat_id": args.deliver_chat_id} @@ -212,6 +216,8 @@ def _cmd_subscribe(args): prompt_preview = route["prompt"][:80] + ("..." if len(route["prompt"]) > 80 else "") label = "Message" if route.get("deliver_only") else "Prompt" print(f" {label}: {prompt_preview}") + if route.get("script"): + print(f" Script: {route['script']}") print("\n Configure your service to POST to the URL above.") print(" Use the secret for HMAC-SHA256 signature validation.") print(" The gateway must be running to receive events (hermes gateway run).\n") @@ -238,6 +244,8 @@ def _cmd_list(args): print(f" URL: {base_url}/webhooks/{name}") print(f" Events: {events}") print(f" Deliver: {deliver}") + if route.get("script"): + print(f" Script: {route['script']}") print() diff --git a/tests/gateway/test_webhook_adapter.py b/tests/gateway/test_webhook_adapter.py index a1e235f46e63..40d15ecd49fc 100644 --- a/tests/gateway/test_webhook_adapter.py +++ b/tests/gateway/test_webhook_adapter.py @@ -608,6 +608,285 @@ class TestEventFilter: assert resp.status == 202 +# =================================================================== +# Payload filters +# =================================================================== + + +class TestPayloadFilters: + """Tests for route-level payload filters in _handle_webhook.""" + + @pytest.mark.asyncio + async def test_filter_rejects_before_agent_dispatch(self): + """A non-matching filter returns ignored and never starts the agent.""" + routes = { + "todoist": { + "secret": _INSECURE_NO_AUTH, + "filters": [{"field": "payload.label", "equals": "urgent"}], + "prompt": "Task: {payload.content}", + } + } + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/todoist", + json={"payload": {"label": "later", "content": "Buy milk"}}, + headers={"X-GitHub-Delivery": "filter-skip-1"}, + ) + assert resp.status == 200 + data = await resp.json() + assert data == { + "status": "ignored", + "reason": "filter", + "route": "todoist", + } + + adapter.handle_message.assert_not_called() + assert "filter-skip-1" not in adapter._seen_deliveries + + @pytest.mark.asyncio + async def test_filter_accepts_nested_any_and_in_file(self, tmp_path, monkeypatch): + """Nested any groups can match dynamic watchlists under HERMES_HOME.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + watchlist = tmp_path / "data" / "watchlist.json" + watchlist.parent.mkdir() + watchlist.write_text(json.dumps(["chat-1", "chat-2"]), encoding="utf-8") + routes = { + "waha": { + "secret": _INSECURE_NO_AUTH, + "filters": [ + {"field": "payload.fromMe", "equals": False}, + { + "any": [ + { + "field": "payload.chatId", + "in_file": "~/.hermes/data/watchlist.json", + }, + { + "field": "payload.id.remote", + "in_file": "~/.hermes/data/watchlist.json", + }, + ] + }, + ], + "prompt": "Message from {payload.chatId}: {payload.body}", + } + } + adapter = _make_adapter(routes=routes) + captured = [] + + async def _capture(event): + captured.append(event) + + adapter.handle_message = _capture + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/waha", + json={ + "payload": { + "fromMe": False, + "chatId": "chat-2", + "body": "hello", + } + }, + headers={"X-GitHub-Delivery": "filter-match-1"}, + ) + assert resp.status == 202 + + await asyncio.sleep(0.05) + assert len(captured) == 1 + assert captured[0].text == "Message from chat-2: hello" + + @pytest.mark.asyncio + async def test_filter_applies_to_deliver_only_before_delivery(self): + """Filtered direct-delivery routes skip target delivery too.""" + routes = { + "alerts": { + "secret": _INSECURE_NO_AUTH, + "deliver": "telegram", + "deliver_only": True, + "deliver_extra": {"chat_id": "123"}, + "filters": [{"field": "severity", "in": ["critical"]}], + "prompt": "Alert: {message}", + } + } + adapter = _make_adapter(routes=routes) + mock_target = AsyncMock() + mock_target.send = AsyncMock(return_value=SendResult(success=True)) + mock_runner = MagicMock() + mock_runner.adapters = {Platform.TELEGRAM: mock_target} + adapter.gateway_runner = mock_runner + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/alerts", + json={"severity": "info", "message": "noise"}, + headers={"X-GitHub-Delivery": "filter-direct-1"}, + ) + assert resp.status == 200 + assert (await resp.json())["reason"] == "filter" + + mock_target.send.assert_not_called() + + @pytest.mark.asyncio + async def test_script_transforms_payload_before_prompt_rendering(self, tmp_path, monkeypatch): + """A script can replace the payload used by prompt templates.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + scripts = tmp_path / "scripts" + scripts.mkdir() + script = scripts / "todoist_filter.py" + script.write_text( + "import json, sys\n" + "payload = json.load(sys.stdin)\n" + "payload['body'] = payload['task']['content'].upper()\n" + "print(json.dumps(payload))\n", + encoding="utf-8", + ) + routes = { + "todoist": { + "secret": _INSECURE_NO_AUTH, + "script": "todoist_filter.py", + "prompt": "Task: {body}", + } + } + adapter = _make_adapter(routes=routes) + captured = [] + + async def _capture(event): + captured.append(event) + + adapter.handle_message = _capture + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/todoist", + json={"task": {"content": "pay bills"}}, + headers={"X-GitHub-Delivery": "script-transform-1"}, + ) + assert resp.status == 202 + + await asyncio.sleep(0.05) + assert captured[0].text == "Task: PAY BILLS" + assert captured[0].raw_message["body"] == "PAY BILLS" + + @pytest.mark.asyncio + async def test_script_tilde_hermes_path_resolves_to_active_profile_home(self, tmp_path, monkeypatch): + """~/.hermes/scripts paths must resolve through HERMES_HOME for profiles.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + scripts = tmp_path / "scripts" + scripts.mkdir() + (scripts / "todoist_filter.py").write_text( + "import json, sys\n" + "payload = json.load(sys.stdin)\n" + "payload['body'] = 'profile-safe'\n" + "print(json.dumps(payload))\n", + encoding="utf-8", + ) + routes = { + "todoist": { + "secret": _INSECURE_NO_AUTH, + "script": "~/.hermes/scripts/todoist_filter.py", + "prompt": "Task: {body}", + } + } + adapter = _make_adapter(routes=routes) + captured = [] + + async def _capture(event): + captured.append(event) + + adapter.handle_message = _capture + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/todoist", + json={"task": {"content": "pay bills"}}, + headers={"X-GitHub-Delivery": "script-profile-path-1"}, + ) + assert resp.status == 202 + + await asyncio.sleep(0.05) + assert captured[0].text == "Task: profile-safe" + + @pytest.mark.asyncio + async def test_script_silent_stdout_ignores_without_idempotency_hit(self, tmp_path, monkeypatch): + """Empty or [SILENT] script stdout filters the webhook out.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + scripts = tmp_path / "scripts" + scripts.mkdir() + (scripts / "skip.py").write_text("print('[SILENT]')\n", encoding="utf-8") + routes = { + "todoist": { + "secret": _INSECURE_NO_AUTH, + "script": "skip.py", + "prompt": "Task: {body}", + } + } + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/todoist", + json={"body": "ignore me"}, + headers={"X-GitHub-Delivery": "script-silent-1"}, + ) + assert resp.status == 200 + data = await resp.json() + assert data == { + "status": "ignored", + "reason": "script", + "route": "todoist", + } + + adapter.handle_message.assert_not_called() + assert "script-silent-1" not in adapter._seen_deliveries + + @pytest.mark.asyncio + async def test_script_nonzero_exit_ignores_webhook(self, tmp_path, monkeypatch): + """A script can fail closed by exiting nonzero.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + scripts = tmp_path / "scripts" + scripts.mkdir() + (scripts / "skip.py").write_text( + "import sys\nsys.exit(2)\n", + encoding="utf-8", + ) + routes = { + "todoist": { + "secret": _INSECURE_NO_AUTH, + "script": "skip.py", + "prompt": "Task: {body}", + } + } + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/todoist", + json={"body": "ignore me"}, + headers={"X-GitHub-Delivery": "script-nonzero-1"}, + ) + assert resp.status == 200 + data = await resp.json() + assert data["status"] == "ignored" + assert data["reason"] == "script" + + adapter.handle_message.assert_not_called() + assert "script-nonzero-1" not in adapter._seen_deliveries + + # =================================================================== # HTTP handling # =================================================================== @@ -1246,4 +1525,3 @@ class TestInsecureNoAuthSafetyRail: assert result is True finally: await adapter.disconnect() - diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py index e0e083c5d192..ee93c0c49512 100644 --- a/tests/hermes_cli/test_dashboard_admin_endpoints.py +++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py @@ -223,6 +223,30 @@ class TestWebhookEndpoints: r = self.client.post("/api/webhooks", json={"name": "gh", "deliver": "log"}) assert r.status_code == 400 + def test_create_webhook_persists_script(self): + from hermes_cli.config import load_config, save_config + + cfg = load_config() + cfg.setdefault("platforms", {})["webhook"] = { + "enabled": True, + "extra": {"host": "0.0.0.0", "port": 8644}, + } + save_config(cfg) + + r = self.client.post( + "/api/webhooks", + json={ + "name": "todoist", + "deliver": "log", + "script": "todoist_filter.py", + }, + ) + assert r.status_code == 200 + assert r.json()["script"] == "todoist_filter.py" + + subs = self.client.get("/api/webhooks").json()["subscriptions"] + assert subs[0]["script"] == "todoist_filter.py" + def test_enable_platform_starts_gateway_restart(self, monkeypatch): import hermes_cli.web_server as ws from hermes_cli.config import load_config diff --git a/tests/hermes_cli/test_webhook_cli.py b/tests/hermes_cli/test_webhook_cli.py index 46f6da84980e..1ac5add13eb6 100644 --- a/tests/hermes_cli/test_webhook_cli.py +++ b/tests/hermes_cli/test_webhook_cli.py @@ -35,6 +35,7 @@ def _make_args(**kwargs): "deliver_chat_id": "", "secret": "", "payload": "", + "script": "", } defaults.update(kwargs) return Namespace(**defaults) @@ -72,6 +73,12 @@ class TestSubscribe: )) assert _load_subscriptions()["s"]["secret"] == "my-secret" + def test_script_option_is_persisted(self): + webhook_command(_make_args( + webhook_action="subscribe", name="s", script="todoist_filter.py" + )) + assert _load_subscriptions()["s"]["script"] == "todoist_filter.py" + def test_auto_secret(self): webhook_command(_make_args(webhook_action="subscribe", name="s")) secret = _load_subscriptions()["s"]["secret"] diff --git a/website/docs/guides/webhook-github-pr-review.md b/website/docs/guides/webhook-github-pr-review.md index 8c0551a5af17..8935a5d48a01 100644 --- a/website/docs/guides/webhook-github-pr-review.md +++ b/website/docs/guides/webhook-github-pr-review.md @@ -182,12 +182,20 @@ tail -f "${HERMES_HOME:-$HOME/.hermes}/logs/gateway.log" ## Filtering to specific actions -GitHub sends `pull_request` events for many actions: `opened`, `synchronize`, `reopened`, `closed`, `labeled`, etc. The `events` list filters only by the `X-GitHub-Event` header value — it cannot filter by action sub-type at the routing level. +GitHub sends `pull_request` events for many actions: `opened`, `synchronize`, `reopened`, `closed`, `labeled`, etc. The `events` list filters by the `X-GitHub-Event` header value, and route-level `filters` can narrow by payload fields such as `action`. The prompt in Step 1 already handles this by instructing the agent to stop early for `closed` and `labeled` events. :::warning The agent still runs and consumes tokens -The "stop here" instruction prevents a meaningful review, but the agent still runs to completion for every `pull_request` event regardless of action. GitHub webhooks can only filter by event type (`pull_request`, `push`, `issues`, etc.) — not by action sub-type (`opened`, `closed`, `labeled`). There is no routing-level filter for sub-actions. For high-volume repos, accept this cost or filter upstream with a GitHub Actions workflow that calls your webhook URL conditionally. +The "stop here" instruction prevents a meaningful review, but the agent still runs to completion for every `pull_request` event regardless of action. Prefer filtering before the agent wakes: + +```yaml +filters: + - field: "action" + in: ["opened", "synchronize", "reopened"] +``` + +For high-volume repositories, you can still filter upstream with a GitHub Actions workflow that calls your webhook URL conditionally. ::: > There is no Jinja2 or conditional template syntax. `{field}` and `{nested.field}` are the only substitutions supported. Anything else is passed verbatim to the agent. diff --git a/website/docs/user-guide/messaging/webhooks.md b/website/docs/user-guide/messaging/webhooks.md index 0a8296f0dc56..e8bc90b2924a 100644 --- a/website/docs/user-guide/messaging/webhooks.md +++ b/website/docs/user-guide/messaging/webhooks.md @@ -81,6 +81,8 @@ Routes define how different webhook sources are handled. Each route is a named e | `events` | No | List of event types to accept (e.g. `["pull_request"]`). If empty, all events are accepted. Event type is read from `X-GitHub-Event`, `X-GitLab-Event`, or `event_type` in the payload. | | `secret` | **Yes** | HMAC secret for signature validation. Falls back to the global `secret` if not set on the route. Set to `"INSECURE_NO_AUTH"` for testing only (skips validation). | | `prompt` | No | Template string with dot-notation payload access (e.g. `{pull_request.title}`). If omitted, the full JSON payload is dumped into the prompt. Payload fields are untrusted — see [Authenticated does not mean trusted](#authenticated-does-not-mean-trusted). | +| `filters` | No | Declarative payload filters evaluated after auth/body/event filtering and before agent or direct delivery work. Non-matches return `{"status":"ignored","reason":"filter"}` with HTTP 200. | +| `script` | No | Filter/transform script under `~/.hermes/scripts/`. The webhook payload is passed as JSON on stdin. JSON object stdout replaces the payload before templating; text stdout is exposed as `script_output`; empty stdout, `[SILENT]`, or a nonzero exit code ignores the webhook. | | `skills` | No | List of skill names to load for the agent run. | | `deliver` | No | Where to send the response: `github_comment`, `telegram`, `discord`, `slack`, `signal`, `sms`, `whatsapp`, `matrix`, `mattermost`, `homeassistant`, `email`, `dingtalk`, `feishu`, `wecom`, `weixin`, `bluebubbles`, `qqbot`, or `log` (default). | | `deliver_extra` | No | Additional delivery config — keys depend on `deliver` type (e.g. `repo`, `pr_number`, `chat_id`). Values support the same `{dot.notation}` templates as `prompt`. | @@ -116,9 +118,75 @@ platforms: events: ["push"] secret: "deploy-secret" prompt: "New push to {repository.full_name} branch {ref}: {head_commit.message}" + filters: + - field: "ref" + equals: "refs/heads/main" deliver: "telegram" ``` +### Payload Filters + +Use `filters` when a provider sends a broad event stream but only some payloads should wake the agent or trigger `deliver_only` delivery. Filters run after signature validation, body parsing, and `events`, but before prompt rendering, idempotency, agent dispatch, or direct delivery. + +```yaml +platforms: + webhook: + extra: + routes: + todoist: + events: ["item:updated"] + secret: "todoist-secret" + filters: + - field: "payload.labels" + contains: "hermes" + - any: + - field: "payload.priority" + equals: 4 + - field: "payload.project_id" + in_file: "~/.hermes/data/todoist/watchlist.json" + prompt: "Todoist task changed: {payload.content}" +``` + +Supported operators: + +- `exists: true|false` +- `missing: true` +- `equals` / `not_equals` +- `contains` for strings, lists, and dict keys +- `in` for inline lists +- `in_file` for JSON arrays, JSON objects (keys are used), or newline-delimited text files +- `regex` +- `all`, `any`, and `not` groups + +Field paths use dot notation. `payload.foo` reads from a top-level `payload` object when one exists, or from the root webhook body for flat payloads. `event` / `event_type` match the resolved event type, and `headers.` reads request headers. + +### Script Filters and Transforms + +Use `script` when declarative filters are not enough. Scripts must live under `~/.hermes/scripts/` for the active profile; relative paths resolve there, and path traversal outside that directory is blocked. `.sh` and `.bash` scripts run with bash, and all other extensions run with the current Python interpreter. + +The route payload is sent to stdin as JSON: + +```python +# ~/.hermes/scripts/todoist-hermes-label.py +import json +import sys + +payload = json.load(sys.stdin) +labels = payload.get("payload", {}).get("labels", []) +if "hermes" not in labels: + print("[SILENT]") + raise SystemExit(0) + +payload["body"] = payload["payload"]["content"] +print(json.dumps(payload)) +``` + +Script outcomes: + +- JSON object stdout replaces the payload used by `prompt` and `deliver_extra`. +- Non-JSON text stdout is added to the payload as `script_output`. +- Empty stdout, exact `[SILENT]`, `{"__hermes_ignore__": true}`, timeout, missing script, or nonzero exit code returns HTTP 200 with `{"status":"ignored","reason":"script"}`. + ### Prompt Templates Prompts use dot-notation to access nested fields in the webhook payload: From ae5e39005bde8deb989c2dda63959cce60bb4622 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:05:30 -0700 Subject: [PATCH 232/610] fix(gateway): run webhook route scripts off the event loop + AUTHOR_MAP entry - run_route_script shells out with subprocess.run (up to 30s timeout); wrap the call in asyncio.to_thread so a slow script can't stall every other webhook and gateway task on the loop. - scripts/release.py: map grace@weeb.onl -> evelynburger for the salvaged contributor commit. --- gateway/platforms/webhook.py | 8 ++++++-- scripts/release.py | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 91cdcf325fa7..e2e52a174f73 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -600,8 +600,12 @@ class WebhookAdapter(BasePlatformAdapter): ) if route_config.get("script"): - keep, transformed_payload = self._route_processor.run_route_script( - route_config.get("script"), payload + # run_route_script shells out (subprocess.run, up to its timeout); + # run it in a worker thread so it can't block the gateway event loop. + keep, transformed_payload = await asyncio.to_thread( + self._route_processor.run_route_script, + route_config.get("script"), + payload, ) if not keep: logger.info( diff --git a/scripts/release.py b/scripts/release.py index 51b2d93b75cb..a73747398f01 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "grace@weeb.onl": "evelynburger", # PR #57544 salvage (gateway: webhook payload filters + route scripts; commit under unlinked identity) "contato@siteup.com.br": "SiteupAgencia", # PR #57435 salvage (tui_gateway: back off notification poller when session is busy; #55578) "164521089+rainbowgits@users.noreply.github.com": "rainbowgore", # PR #59405 salvage (mcp: bound stdio initialize handshake to stop subprocess/FD leak; #59349) "sage@Sages-Mac-mini.local": "thestudionorth", # PR #60015 salvage (mcp: parent-death watchdog for stdio children; commit under unlinked local identity) From 8e734810dfcb4f19eac2f442cb75b3962dfec728 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:14:31 -0700 Subject: [PATCH 233/610] fix(desktop): continue the selected stored session instead of minting a new one (#55578) (#60874) Two client-side halves of the #55578 session split: 1. Submit with a null activeSessionId but a SELECTED stored session now resumes that stored session instead of falling straight through to createBackendSessionForSend - which silently forked the user's conversation into a brand-new session that then got orphan-reaped. New-chat drafts (no stored selection) still create sessions as before. 2. prompt.submit recovery now also fires on gateway request timeouts, not only 'session not found'. A starved backend loop (the async- delegation poller spin) rejects the submit with 'request timed out' even though the stored session is fine; previously that surfaced an error, left the binding cleared, and set up the split on the next send. Fail-then-pass: 2 new tests fail with production code reverted. --- .../hooks/use-prompt-actions/index.test.tsx | 132 +++++++++++++++++- .../hooks/use-prompt-actions/submit.ts | 38 ++++- .../session/hooks/use-prompt-actions/utils.ts | 12 ++ 3 files changed, 177 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index 681002311fdf..5ce2ad8aa569 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -59,7 +59,9 @@ function Harness({ requestGateway, resumeStoredSession, seedMessages, - storedSessionId + storedSessionId, + activeSessionId, + createBackendSessionForSend }: { busyRef?: MutableRefObject onReady: (handle: HarnessHandle) => void @@ -70,8 +72,12 @@ function Harness({ resumeStoredSession?: (storedSessionId: string) => Promise | void seedMessages?: unknown[] storedSessionId?: null | string + activeSessionId?: null | string + createBackendSessionForSend?: () => Promise }) { - const activeSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID } + const activeSessionIdRef: MutableRefObject = { + current: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId + } const selectedStoredSessionIdRef: MutableRefObject = { current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId @@ -87,11 +93,11 @@ function Harness({ } as never) const actions = usePromptActions({ - activeSessionId: RUNTIME_SESSION_ID, + activeSessionId: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId, activeSessionIdRef, branchCurrentSession: async () => true, busyRef: localBusyRef, - createBackendSessionForSend: async () => RUNTIME_SESSION_ID, + createBackendSessionForSend: createBackendSessionForSend ?? (async () => RUNTIME_SESSION_ID), handleSkinCommand: () => '', openMemoryGraph: openMemoryGraph ?? (() => undefined), refreshSessions, @@ -1190,6 +1196,124 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(await handle!.submitText('message')).toBe(false) expect(calls).not.toContain('session.resume') }) + + it('recovers via session.resume when prompt.submit TIMES OUT and a stored session is selected (#55578)', async () => { + // A starved gateway loop rejects with "request timed out: prompt.submit". + // With a stored session selected, that must recover exactly like + // "session not found" — resume + retry — not surface an error that leaves + // activeSessionId null and lets the next send mint a new session. + const calls: { method: string; params?: Record }[] = [] + let submitAttempts = 0 + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (method === 'prompt.submit') { + submitAttempts += 1 + + if (submitAttempts === 1) { + throw new Error('request timed out: prompt.submit') + } + + return {} as never + } + + if (method === 'session.resume') { + return { session_id: RECOVERED_SESSION_ID } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={STORED_SESSION_ID} + /> + ) + + const ok = await handle!.submitText('message during starved loop') + + expect(ok).toBe(true) + expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit']) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID }) + expect(calls[2]?.params).toEqual({ + session_id: RECOVERED_SESSION_ID, + text: 'message during starved loop' + }) + }) + + it('resumes the SELECTED stored session instead of minting a new one when activeSessionId is null (#55578 split)', async () => { + // The exact split path from #55578 symptom (b): the runtime binding is + // gone (orphan-reaped / cleared by a timeout) but a stored session is + // still selected in the sidebar. A follow-up submit must continue that + // conversation via session.resume — createBackendSessionForSend would + // silently fork the user's chat in two. + const calls: { method: string; params?: Record }[] = [] + const createBackendSessionForSend = vi.fn(async () => 'brand-new-session-WRONG') + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (method === 'session.resume') { + return { session_id: RECOVERED_SESSION_ID } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={STORED_SESSION_ID} + /> + ) + + const ok = await handle!.submitText('follow-up in the selected chat') + + expect(ok).toBe(true) + expect(createBackendSessionForSend).not.toHaveBeenCalled() + expect(calls.map(c => c.method)).toEqual(['session.resume', 'prompt.submit']) + expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID }) + expect(calls[1]?.params).toMatchObject({ session_id: RECOVERED_SESSION_ID }) + }) + + it('still creates a new session for a genuine new-chat draft (no stored session selected)', async () => { + const createBackendSessionForSend = vi.fn(async () => RUNTIME_SESSION_ID) + const calls: string[] = [] + + const requestGateway = vi.fn(async (method: string) => { + calls.push(method) + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={null} + /> + ) + + const ok = await handle!.submitText('first message of a new chat') + + expect(ok).toBe(true) + expect(createBackendSessionForSend).toHaveBeenCalledTimes(1) + expect(calls).not.toContain('session.resume') + }) }) describe('usePromptActions eager attachment upload (drop-time)', () => { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 09df1221af84..76a25885854c 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -23,6 +23,7 @@ import { inlineErrorMessage, isProviderSetupError, isSessionBusyError, + isGatewayTimeoutError, isSessionNotFoundError, type SubmitTextOptions, withSessionBusyRetry @@ -213,6 +214,34 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { setMessages(current => [...current, buildUserMessage()]) } + if (!sessionId && selectedStoredSessionIdRef.current) { + // A stored session is SELECTED but its runtime binding is gone (the + // live session was orphan-reaped, or a timeout/reconnect cleared + // activeSessionId). Continuing the selected conversation must mean + // resuming it — minting a brand-new backend session here silently + // splits the user's chat in two (#55578 symptom b). Only fall through + // to session creation when NO stored session is selected (a genuine + // new-chat draft). + try { + const resumed = await requestGateway<{ session_id: string }>('session.resume', { + session_id: selectedStoredSessionIdRef.current + }) + + if (resumed?.session_id) { + sessionId = resumed.session_id + activeSessionIdRef.current = sessionId + } + } catch { + // Resume failed (session gone from state.db, gateway hiccup) — + // fall through to creating a fresh session rather than dead-ending + // the user's message. + } + + if (sessionId) { + seedOptimistic(sessionId) + } + } + if (!sessionId) { try { sessionId = await createBackendSessionForSend(visibleText) @@ -257,8 +286,15 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) ) } catch (firstErr) { - if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) { + if ( + (isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) && + selectedStoredSessionIdRef.current + ) { // Re-register the session in the gateway and get a fresh live ID. + // Timeouts recover the same way as "session not found": a starved + // backend loop (#55578 symptom d) rejects the submit even though + // the stored session is fine — resume + retry instead of erroring + // out and losing the session binding. const resumed = await requestGateway<{ session_id: string }>('session.resume', { session_id: selectedStoredSessionIdRef.current, source: 'desktop' diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index 00fc50855321..1df123824d1e 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -52,6 +52,18 @@ export function isSessionNotFoundError(error: unknown): boolean { return /session not found/i.test(message) } +// Gateway JSON-RPC calls reject with "request timed out: " when the +// backend event loop is starved (e.g. a poller spin or a heavy async-injected +// turn). For prompt.submit this is indistinguishable from a dead runtime +// session on the client side — recovery must treat it like one (#55578): +// resume the SELECTED stored session and retry, instead of surfacing an error +// that leads to a null activeSessionId and a silently minted new session. +export function isGatewayTimeoutError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + + return /request timed out/i.test(message) +} + // The gateway refuses prompt.submit while a turn is running (4009 "session // busy"). It's a transient concurrency guard, never a user-facing error: a // submit racing the settle edge (or a rewind interrupting mid-turn) just waits From 76381e2a8e3a21fbbd0a192b4b5f7356a7ca47b8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:56:17 -0700 Subject: [PATCH 234/610] =?UTF-8?q?fix(compression):=20stop=20compaction?= =?UTF-8?q?=20thrash=20=E2=80=94=2075%=20trigger=20floor=20under=20512K,?= =?UTF-8?q?=20no=20summary=20output=20cap,=20reasoning-trace=20exclusion?= =?UTF-8?q?=20(#60989)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions on sub-512K-context models were spending most of their wall-clock re-summarizing: the 50% trigger left too little post-compaction headroom (the incompressible floor — system prompt, tool schemas, protected tail, rolling summary — ate most of the reclaimed space), so compaction re-fired every 1-2 turns. Three compounding defects fixed: - Threshold floor: models with context windows below 512K now trigger at >=75% of the window (raise-only — a higher configured value or per-model autoraise like Codex gpt-5.5's 85% always wins). Re-derived on update_model() in both directions. - No max_tokens on the summary call: the summary budget is prompt guidance only ("Target ~N tokens"). The wire cap truncated summaries mid-section on the Anthropic Messages / NVIDIA NIM paths (thinking models burn the cap on reasoning first), yielding truncated or thinking-only summaries and compaction loops. Summary token ceiling lowered 12K -> 10K to keep the guidance within the intended 1K-10K envelope. - Reasoning traces excluded end-to-end: inline / blocks are now stripped from assistant content before serialization to the summarizer, and from the summarizer's own output before the summary is stored (previously a thinking summarizer model's trace was persisted in _previous_summary and re-fed into every iterative update, compounding bloat). Native reasoning fields were already excluded. Verified E2E with real imports against a temp HERMES_HOME: threshold table across 64K-1M windows, override interactions (user 0.85 wins, spark 0.70 raised, gpt-5.5 0.85 kept), full compress() round-trip with a thinking summarizer, and wire-kwargs capture proving no max_tokens is sent. --- agent/context_compressor.py | 92 ++++++++- cli-config.yaml.example | 2 + hermes_cli/config.py | 6 +- ...t_compression_small_ctx_threshold_floor.py | 186 ++++++++++++++++++ tests/agent/test_context_compressor.py | 28 +-- .../test_infinite_compaction_loop.py | 47 ++--- 6 files changed, 321 insertions(+), 40 deletions(-) create mode 100644 tests/agent/test_compression_small_ctx_threshold_floor.py diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 45eb25e1ca0b..d952470fac2d 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -193,8 +193,10 @@ _HISTORICAL_SUMMARY_PREFIXES = ( _MIN_SUMMARY_TOKENS = 2000 # Proportion of compressed content to allocate for summary _SUMMARY_RATIO = 0.20 -# Absolute ceiling for summary tokens (even on very large context windows) -_SUMMARY_TOKENS_CEILING = 12_000 +# Absolute ceiling for summary tokens (even on very large context windows). +# Summaries must stay within a 1K-10K token envelope — anything larger is +# itself a context-pressure source and slows every compaction. +_SUMMARY_TOKENS_CEILING = 10_000 # Placeholder used when pruning old tool results _PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]" @@ -227,6 +229,16 @@ _AUTO_FOCUS_MAX_CHARS = 700 # back the old large-tool-output case where nothing can be compacted. _MAX_TAIL_MESSAGE_FLOOR = 8 +# Models with context windows below this get their compression threshold +# floored at ``_SMALL_CTX_THRESHOLD_PERCENT`` (raise-only — an explicitly +# higher user/model threshold always wins). At the default 50% trigger a +# 128K-262K model compacts with only ~64-131K consumed; the incompressible +# floor (system prompt + tool schemas + protected tail + rolling summary) +# eats most of the reclaimed headroom, so compaction re-fires every 1-2 +# turns and the session spends most of its wall-clock summarizing. +_SMALL_CTX_WINDOW_LIMIT = 512_000 +_SMALL_CTX_THRESHOLD_PERCENT = 0.75 + _PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+") @@ -883,6 +895,18 @@ class ContextCompressor(ContextEngine): self.provider = provider self.api_mode = api_mode self.context_length = context_length + # Re-apply the small-context threshold floor for the NEW window, + # starting from the originally-configured percent (not the possibly + # floored live value) so a small -> large switch drops back to the + # configured threshold and a large -> small switch gains the floor. + # Guard with getattr: compressors unpickled/constructed before this + # attribute existed fall back to the live value. + _configured_pct = getattr( + self, "_configured_threshold_percent", self.threshold_percent, + ) + self.threshold_percent = self._effective_threshold_percent( + context_length, _configured_pct, + ) # max_tokens=None here means "caller didn't specify" → keep the existing # output reservation. A switch that genuinely changes the output budget # passes the new value explicitly. (#43547) @@ -945,6 +969,23 @@ class ContextCompressor(ContextEngine): return None return ivalue if ivalue > 0 else None + @staticmethod + def _effective_threshold_percent( + context_length: int, threshold_percent: float, + ) -> float: + """Apply the small-context threshold floor (raise-only). + + Models under ``_SMALL_CTX_WINDOW_LIMIT`` (512K) trigger at no less + than ``_SMALL_CTX_THRESHOLD_PERCENT`` (75%) of the window. An + explicitly higher threshold (user config or per-model autoraise, + e.g. Codex gpt-5.5's 85%) always wins; only lower values are raised. + Large-context models keep the configured value — at 512K+ the default + 50% trigger already leaves ample post-compaction headroom. + """ + if context_length and context_length < _SMALL_CTX_WINDOW_LIMIT: + return max(threshold_percent, _SMALL_CTX_THRESHOLD_PERCENT) + return threshold_percent + @staticmethod def _compute_threshold_tokens( context_length: int, threshold_percent: float, max_tokens: int | None = None, @@ -1032,6 +1073,18 @@ class ContextCompressor(ContextEngine): config_context_length=config_context_length, provider=provider, ) + # Small-context threshold floor: models under 512K trigger at >=75% + # so compaction doesn't fire with half the window still free (the + # incompressible floor makes 50%-triggered compaction thrash on + # 128K-262K models). Raise-only; must run AFTER context_length is + # resolved and BEFORE threshold_tokens is derived. The pre-floor + # value is kept so update_model() can re-derive for a new window + # (switching small -> large must drop back to the configured value). + self._configured_threshold_percent = self.threshold_percent + self.threshold_percent = self._effective_threshold_percent( + self.context_length, self.threshold_percent, + ) + threshold_percent = self.threshold_percent # Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even if # the percentage would suggest a lower value. This prevents premature # compression on large-context models at 50% while keeping the % sane @@ -1410,11 +1463,26 @@ class ContextCompressor(ContextEngine): (API keys, tokens, passwords) from leaking into the summary that gets sent to the auxiliary model and persisted across compactions. """ + # Lazy import (matches title_generator.py) — agent_runtime_helpers + # pulls in heavy transitive imports we don't want at module load. + from agent.agent_runtime_helpers import strip_think_blocks + parts = [] for msg in turns: role = msg.get("role", "unknown") content = redact_sensitive_text(msg.get("content") or "") content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content) + # Strip inline reasoning blocks (, , etc.) from + # assistant content before it reaches the summarizer. Reasoning + # traces are transient scratch work — feeding them to the aux + # model wastes summarizer context and risks scratch-work + # conclusions being preserved as facts in the summary. The native + # ``reasoning`` message field is already excluded (only + # ``content`` is serialized); this closes the inline-tag path + # used when native thinking is disabled or the provider inlines + # traces into content. + if role == "assistant" and content: + content = strip_think_blocks(None, content) # Tool results: keep enough content for the summarizer if role == "tool": @@ -1880,7 +1948,15 @@ This compaction should PRIORITISE preserving all information related to the focu "api_mode": self.api_mode, }, "messages": [{"role": "user", "content": prompt}], - "max_tokens": int(summary_budget * 1.3), + # NO max_tokens: the output cap must never truncate a summary. + # ``summary_budget`` is prompt-level guidance only ("Target ~N + # tokens" above). Most OpenAI-compatible wires already omit the + # param (see _build_call_kwargs), but the Anthropic Messages + # wire and NVIDIA NIM forward it — a hard cap there cut + # summaries mid-section (thinking models burn the cap on + # reasoning first), producing truncated/thinking-only + # summaries and compaction loops. Omitting lets the adapter + # fall back to the model's native output ceiling. # timeout resolved from auxiliary.compression.timeout config by call_llm } if self.summary_model: @@ -1920,6 +1996,16 @@ This compaction should PRIORITISE preserving all information related to the focu f"(provider={self.provider or 'auto'} " f"model={self.summary_model or self.model})" ) + # Strip reasoning blocks the summarizer model may have emitted + # (... etc. from thinking models like MiniMax, + # DeepSeek, QwQ). Without this the trace is stored in + # _previous_summary, injected into the conversation, AND fed back + # into every subsequent iterative-update prompt — compounding + # token bloat across compactions. Mirrors title_generator.py. + from agent.agent_runtime_helpers import strip_think_blocks + stripped = strip_think_blocks(None, content).strip() + if stripped: + content = stripped # Redact the summary output as well — the summarizer LLM may # ignore prompt instructions and echo back secrets verbatim. summary = redact_sensitive_text(content.strip()) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index ed7300da71d9..69acc6868057 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -409,6 +409,8 @@ compression: # Trigger compression at this % of model's context limit (default: 0.50 = 50%) # Lower values = more aggressive compression, higher values = compress later + # Models with context windows below 512K are floored at 0.75 (raise-only) so + # compaction doesn't fire with half the window still free; set above 0.75 to override. threshold: 0.50 # Existing Codex gpt-5.5 behavior: raise Hermes' compaction trigger to 85% diff --git a/hermes_cli/config.py b/hermes_cli/config.py index e24cc220f4fa..7c83c6f167aa 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1397,7 +1397,11 @@ DEFAULT_CONFIG = { "compression": { "enabled": True, - "threshold": 0.50, # compress when context usage exceeds this ratio + "threshold": 0.50, # compress when context usage exceeds this ratio. + # Models with context windows below 512K are + # floored at 0.75 (raise-only) so compaction + # doesn't fire with half the window still free; + # set this above 0.75 to override the floor. "target_ratio": 0.20, # fraction of threshold to preserve as recent tail "protect_last_n": 20, # minimum recent messages to keep uncompressed "hygiene_hard_message_limit": 5000, # gateway session-hygiene force-compress threshold by message count diff --git a/tests/agent/test_compression_small_ctx_threshold_floor.py b/tests/agent/test_compression_small_ctx_threshold_floor.py new file mode 100644 index 000000000000..03c64a00cbde --- /dev/null +++ b/tests/agent/test_compression_small_ctx_threshold_floor.py @@ -0,0 +1,186 @@ +"""Compression hygiene: small-context threshold floor, reasoning-trace +exclusion, and bounded summary size. + +Covers the July 2026 compression tuning pass: + +1. Reasoning traces (native ``reasoning`` field AND inline ````-style + blocks) must never reach the summarizer prompt, and traces emitted BY the + summarizer model must never be stored in the summary. +2. Head/tail protection budgets stay proportionate (tail = 20% of threshold). +3. Summary token budget is bounded to the 1K-10K envelope. +4. Models with context windows below 512K get their compression threshold + floored at 75% (raise-only — a higher configured value always wins). +""" + +from unittest.mock import patch + +import agent.context_compressor as cc +from agent.context_compressor import ContextCompressor + + +def _make(ctx: int, pct: float = 0.50) -> ContextCompressor: + with patch.object(cc, "get_model_context_length", return_value=ctx): + return ContextCompressor( + model="test/model", threshold_percent=pct, quiet_mode=True, + ) + + +class TestSmallContextThresholdFloor: + def test_sub_512k_floors_to_75_percent(self): + for ctx in (128_000, 200_000, 262_144, 511_999): + comp = _make(ctx, pct=0.50) + assert comp.threshold_percent == 0.75, ctx + assert comp.threshold_tokens == int(ctx * 0.75), ctx + + def test_512k_and_above_keep_configured_percent(self): + for ctx in (512_000, 1_000_000): + comp = _make(ctx, pct=0.50) + assert comp.threshold_percent == 0.50, ctx + assert comp.threshold_tokens == int(ctx * 0.50), ctx + + def test_raise_only_higher_config_wins(self): + # Explicit 85% (user config or Codex gpt-5.5 autoraise) is not lowered. + comp = _make(128_000, pct=0.85) + assert comp.threshold_percent == 0.85 + + def test_degenerate_minimum_window_still_uses_85(self): + # 64K window: the MINIMUM_CONTEXT_LENGTH floor pushes the threshold + # to/over the window, so the 85% degenerate-window guard still rules. + comp = _make(64_000, pct=0.50) + assert comp.threshold_tokens == 54_400 # 85% of 64000 + + def test_update_model_rederives_floor_both_directions(self): + comp = _make(128_000, pct=0.50) + assert comp.threshold_percent == 0.75 + # small -> large: back to the configured 50% + comp.update_model("big", 1_000_000) + assert comp.threshold_percent == 0.50 + assert comp.threshold_tokens == 500_000 + # large -> small: floor re-applies + comp.update_model("small", 200_000) + assert comp.threshold_percent == 0.75 + assert comp.threshold_tokens == 150_000 + + +class TestReasoningExcludedFromSummarizer: + def test_serializer_drops_inline_think_blocks(self): + comp = _make(128_000) + turns = [ + {"role": "user", "content": "do the thing"}, + {"role": "assistant", "content": "INLINE_TRACEvisible answer"}, + {"role": "assistant", "content": "VARIANT_TRACEother answer"}, + ] + ser = comp._serialize_for_summary(turns) + assert "INLINE_TRACE" not in ser + assert "VARIANT_TRACE" not in ser + assert "visible answer" in ser + assert "other answer" in ser + + def test_serializer_excludes_native_reasoning_field(self): + comp = _make(128_000) + turns = [{"role": "assistant", "content": "done", "reasoning": "NATIVE_TRACE"}] + ser = comp._serialize_for_summary(turns) + assert "NATIVE_TRACE" not in ser + assert "done" in ser + + def test_summarizer_output_think_block_stripped_before_store(self): + comp = _make(128_000) + + class FakeMsg: + content = "OUTPUT_TRACE\n## Active Task\nUser asked X" + + class FakeChoice: + message = FakeMsg() + + class FakeResp: + choices = [FakeChoice()] + + with patch.object(cc, "call_llm", return_value=FakeResp()): + out = comp._generate_summary([{"role": "user", "content": "hi"}]) + assert out is not None + assert "OUTPUT_TRACE" not in out + assert "## Active Task" in out + # The iterative-update seed must be clean too, or the trace compounds + # across every subsequent compaction. + assert "OUTPUT_TRACE" not in (comp._previous_summary or "") + + def test_thinking_only_summarizer_response_not_blanked(self): + # If stripping removes everything (degenerate model output), keep the + # raw content instead of storing an empty summary. + comp = _make(128_000) + + class FakeMsg: + content = "only reasoning, no body" + + class FakeChoice: + message = FakeMsg() + + class FakeResp: + choices = [FakeChoice()] + + with patch.object(cc, "call_llm", return_value=FakeResp()): + out = comp._generate_summary([{"role": "user", "content": "hi"}]) + # Falls back to unstripped content rather than an empty summary body. + assert out is not None and out.strip() + + +class TestSummaryBudgetEnvelope: + def test_no_max_tokens_wire_cap_on_summary_call(self): + """The summary budget is PROMPT GUIDANCE only ("Target ~N tokens"). + + A wire-level max_tokens cap truncates summaries mid-section on the + Anthropic Messages / NVIDIA NIM paths (which forward the param), and + thinking models burn the cap on reasoning before emitting the summary + body — producing truncated or thinking-only summaries and compaction + loops. The call must NOT carry max_tokens. + """ + comp = _make(128_000) + captured = {} + + class FakeMsg: + content = "## Active Task\nUser asked X" + + class FakeChoice: + message = FakeMsg() + + class FakeResp: + choices = [FakeChoice()] + + def fake_call_llm(**kw): + captured.update(kw) + return FakeResp() + + with patch.object(cc, "call_llm", side_effect=fake_call_llm): + out = comp._generate_summary([{"role": "user", "content": "hi"}]) + assert out is not None + assert "max_tokens" not in captured + # The budget still lands as prompt guidance, within the envelope. + prompt = captured["messages"][0]["content"] + import re + m = re.search(r"Target ~(\d+) tokens", prompt) + assert m, "prompt-level token target guidance missing" + assert 1_000 <= int(m.group(1)) <= 10_000 + + def test_budget_capped_at_10k_even_on_1m_window(self): + comp = _make(1_000_000) + huge = [{"role": "assistant", "content": "x" * 8000} for _ in range(200)] + assert comp._compute_summary_budget(huge) <= 10_000 + assert comp.max_summary_tokens <= 10_000 + + def test_budget_floor_stays_in_envelope(self): + comp = _make(1_000_000) + tiny = [{"role": "user", "content": "hi"}] + budget = comp._compute_summary_budget(tiny) + assert 1_000 <= budget <= 10_000 + + def test_ceiling_constant_within_envelope(self): + assert 1_000 <= cc._SUMMARY_TOKENS_CEILING <= 10_000 + assert 1_000 <= cc._MIN_SUMMARY_TOKENS <= 10_000 + + +class TestTailBudgetProportionality: + def test_tail_budget_is_target_ratio_of_threshold(self): + comp = _make(128_000) + assert comp.tail_token_budget == int(comp.threshold_tokens * comp.summary_target_ratio) + # Sanity: tail protection stays a modest slice of the window (<= 20%). + assert comp.tail_token_budget <= comp.context_length * 0.20 diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 75b22bb8a80d..252737e3e851 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -2305,8 +2305,8 @@ class TestSummaryTargetRatio: """Tail token budget should be threshold_tokens * summary_target_ratio.""" with patch("agent.context_compressor.get_model_context_length", return_value=200_000): c = ContextCompressor(model="test", quiet_mode=True, summary_target_ratio=0.40) - # 200K * 0.50 threshold * 0.40 ratio = 40K - assert c.tail_token_budget == 40_000 + # 200K < 512K → threshold floored at 75%: 150K * 0.40 ratio = 60K + assert c.tail_token_budget == 60_000 with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000): c = ContextCompressor(model="test", quiet_mode=True, summary_target_ratio=0.40) @@ -2314,14 +2314,14 @@ class TestSummaryTargetRatio: assert c.tail_token_budget == 200_000 def test_summary_cap_scales_with_context(self): - """Max summary tokens should be 5% of context, capped at 12K.""" + """Max summary tokens should be 5% of context, capped at 10K.""" with patch("agent.context_compressor.get_model_context_length", return_value=200_000): c = ContextCompressor(model="test", quiet_mode=True) assert c.max_summary_tokens == 10_000 # 200K * 0.05 with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000): c = ContextCompressor(model="test", quiet_mode=True) - assert c.max_summary_tokens == 12_000 # capped at 12K ceiling + assert c.max_summary_tokens == 10_000 # capped at 10K ceiling def test_ratio_clamped(self): """Ratio should be clamped to [0.10, 0.80].""" @@ -2333,20 +2333,20 @@ class TestSummaryTargetRatio: c = ContextCompressor(model="test", quiet_mode=True, summary_target_ratio=0.95) assert c.summary_target_ratio == 0.80 - def test_default_threshold_is_50_percent(self): - """Default compression threshold should be 50%, with a 64K floor.""" + def test_default_threshold_floored_at_75_percent_below_512k(self): + """Sub-512K models get the 75% small-context threshold floor.""" with patch("agent.context_compressor.get_model_context_length", return_value=100_000): c = ContextCompressor(model="test", quiet_mode=True) - assert c.threshold_percent == 0.50 - # 50% of 100K = 50K, but the floor is 64K - assert c.threshold_tokens == 64_000 + assert c.threshold_percent == 0.75 + # 75% of 100K = 75K, above the 64K minimum floor + assert c.threshold_tokens == 75_000 - def test_threshold_floor_does_not_apply_above_128k(self): - """On large-context models the 50% percentage is used directly.""" - with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + def test_configured_threshold_used_at_512k_and_above(self): + """At 512K+ the configured (default 50%) percentage is used directly.""" + with patch("agent.context_compressor.get_model_context_length", return_value=512_000): c = ContextCompressor(model="test", quiet_mode=True) - # 50% of 200K = 100K, which is above the 64K floor - assert c.threshold_tokens == 100_000 + assert c.threshold_percent == 0.50 + assert c.threshold_tokens == 256_000 def test_default_protect_last_n_is_20(self): """Default protect_last_n should be 20.""" diff --git a/tests/run_agent/test_infinite_compaction_loop.py b/tests/run_agent/test_infinite_compaction_loop.py index 31a7a12ba16e..52a2efa813ab 100644 --- a/tests/run_agent/test_infinite_compaction_loop.py +++ b/tests/run_agent/test_infinite_compaction_loop.py @@ -34,6 +34,9 @@ def _make_compressor(**kwargs) -> ContextCompressor: quiet_mode=True, ) defaults.update(kwargs) + # NOTE: 96K < 512K, so the small-context floor raises the effective + # threshold_percent to 0.75 → threshold_tokens = 72_000. Tests use + # 73_000 as the "over threshold" probe value. with patch("agent.context_compressor.get_model_context_length", return_value=96000): return ContextCompressor(**defaults) @@ -68,14 +71,14 @@ class TestCompressNoOpRegistersIneffective: ) # A large session that passes the min_for_compress check messages = _build_session(10, words_per_turn=10) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 # Mock _find_tail_cut_by_tokens to return head_end, # causing compress_start >= compress_end original = comp._find_tail_cut_by_tokens comp._find_tail_cut_by_tokens = lambda msgs, he: he # force no-op - result = comp.compress(messages, current_tokens=65_000) + result = comp.compress(messages, current_tokens=73_000) assert comp._ineffective_compression_count >= 1, ( f"Expected ineffective_compression_count >= 1, got {comp._ineffective_compression_count}" @@ -88,10 +91,10 @@ class TestCompressNoOpRegistersIneffective: config_context_length=96000, ) messages = _build_session(10, words_per_turn=10) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._find_tail_cut_by_tokens = lambda msgs, he: he # force no-op - comp.compress(messages, current_tokens=65_000) + comp.compress(messages, current_tokens=73_000) assert comp._last_compression_savings_pct == 0.0 @@ -102,14 +105,14 @@ class TestCompressNoOpRegistersIneffective: config_context_length=96000, ) messages = _build_session(10, words_per_turn=10) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._find_tail_cut_by_tokens = lambda msgs, he: he # force no-op - comp.compress(messages, current_tokens=65_000) - comp.compress(messages, current_tokens=65_000) + comp.compress(messages, current_tokens=73_000) + comp.compress(messages, current_tokens=73_000) assert comp._ineffective_compression_count >= 2 - assert not comp.should_compress(65_000), ( + assert not comp.should_compress(73_000), ( "should_compress should return False after 2+ ineffective compressions" ) @@ -120,11 +123,11 @@ class TestCompressNoOpRegistersIneffective: config_context_length=96000, ) messages = _build_session(10, words_per_turn=10) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 original_cut = comp._find_tail_cut_by_tokens comp._find_tail_cut_by_tokens = lambda msgs, he: he # force no-op - result = comp.compress(messages, current_tokens=65_000) + result = comp.compress(messages, current_tokens=73_000) assert len(result) == len(messages), ( f"Expected unchanged message count {len(messages)}, got {len(result)}" @@ -214,9 +217,9 @@ class TestEffectiveCompressionResetsCounter: ) messages = _build_session(30, words_per_turn=100) comp._generate_summary = MagicMock(return_value="Compacted summary of earlier turns.") - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 - comp.compress(messages, current_tokens=65_000) + comp.compress(messages, current_tokens=73_000) assert comp._ineffective_compression_count == 0, ( f"Expected 0 ineffective compressions with effective compression, " @@ -234,16 +237,16 @@ class TestAntiThrashing: def test_ineffective_count_2_blocks(self): """_ineffective_compression_count >= 2 -> should_compress returns False.""" comp = _make_compressor(config_context_length=96000) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._ineffective_compression_count = 2 - assert not comp.should_compress(65_000) + assert not comp.should_compress(73_000) def test_ineffective_count_1_allows(self): """_ineffective_compression_count = 1 -> should_compress still True.""" comp = _make_compressor(config_context_length=96000) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._ineffective_compression_count = 1 - assert comp.should_compress(65_000) + assert comp.should_compress(73_000) def test_below_threshold_allows(self): """Tokens below threshold -> should_compress returns False regardless.""" @@ -266,23 +269,23 @@ class TestCooldownGuard: """A future cooldown deadline -> should_compress returns False even when tokens are over threshold.""" comp = _make_compressor(config_context_length=96000) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._summary_failure_cooldown_until = time.monotonic() + 60 - assert not comp.should_compress(65_000) + assert not comp.should_compress(73_000) def test_expired_cooldown_allows(self): """A past cooldown deadline -> compression resumes normally.""" comp = _make_compressor(config_context_length=96000) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._summary_failure_cooldown_until = time.monotonic() - 1 - assert comp.should_compress(65_000) + assert comp.should_compress(73_000) def test_no_cooldown_allows(self): """The default (no cooldown set) does not block compression.""" comp = _make_compressor(config_context_length=96000) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 assert comp._summary_failure_cooldown_until == 0.0 - assert comp.should_compress(65_000) + assert comp.should_compress(73_000) # --------------------------------------------------------------------------- From aabfedcac03c0615578c798053299b73aad4e4f7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:56:24 -0700 Subject: [PATCH 235/610] docs(webhook): complete filters + route-scripts coverage across doc surfaces (#60983) Follow-up to #60944 (webhook payload filters and route scripts): - reference/cli-commands.md (en+zh): document the new --script option on 'hermes webhook subscribe' - zh-Hans user-guide webhooks.md: mirror the Payload Filters and Script Filters/Transforms sections plus the filters/script route properties (the salvage shipped English-only docs) - hermes-agent skill webhooks reference: teach the agent the filters/ script surface so agent-driven subscriptions can use them --- .../hermes-agent/references/webhooks.md | 16 +++++ website/docs/reference/cli-commands.md | 1 + .../current/reference/cli-commands.md | 1 + .../current/user-guide/messaging/webhooks.md | 68 +++++++++++++++++++ 4 files changed, 86 insertions(+) diff --git a/skills/autonomous-ai-agents/hermes-agent/references/webhooks.md b/skills/autonomous-ai-agents/hermes-agent/references/webhooks.md index 0af935ea23c6..a815cac50d44 100644 --- a/skills/autonomous-ai-agents/hermes-agent/references/webhooks.md +++ b/skills/autonomous-ai-agents/hermes-agent/references/webhooks.md @@ -67,6 +67,22 @@ hermes webhook subscribe \ Returns the webhook URL and HMAC secret. The user configures their service to POST to that URL. +### Filter or transform payloads before the agent runs + +Two mechanisms narrow broad event streams (e.g. Todoist/GitHub fire on every update) so only relevant payloads wake the agent: + +- **Declarative `filters`** (config.yaml routes only): list of conditions on payload fields, event type, or headers — operators `equals`, `not_equals`, `contains`, `exists`, `missing`, `in`, `in_file`, `regex`, with `all`/`any`/`not` grouping. Non-matching events are ignored with HTTP 200. +- **Route scripts** (`--script` on subscribe, or `script:` on a config route): a script under `~/.hermes/scripts/` receives the payload as JSON on stdin. JSON stdout replaces the payload before prompt templating; empty stdout, `[SILENT]`, or a nonzero exit ignores the webhook. `.sh`/`.bash` run with bash, everything else with Python. Scripts cannot live outside `~/.hermes/scripts/` (path traversal is blocked). + +```bash +hermes webhook subscribe todoist-hermes \ + --prompt "Task changed: {payload.content}" \ + --script "todoist-hermes-label.py" \ + --deliver telegram --deliver-chat-id "12345" +``` + +Full filter syntax: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks#payload-filters + ### List subscriptions ```bash hermes webhook list diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index b8ace5b1912a..6fa916786f3c 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -671,6 +671,7 @@ hermes webhook subscribe [options] | `--deliver-chat-id` | Target chat/channel ID for cross-platform delivery. | | `--secret` | Custom HMAC secret. Auto-generated if omitted. | | `--deliver-only` | Skip the agent — deliver the rendered `--prompt` as the literal message. Zero LLM cost, sub-second delivery. Requires `--deliver` to be a real target (not `log`). | +| `--script` | Filter/transform script under `~/.hermes/scripts/`. The webhook payload is passed as JSON on stdin; JSON stdout replaces the payload, and empty stdout, `[SILENT]`, or a nonzero exit code ignores the webhook. See [Script Filters and Transforms](../user-guide/messaging/webhooks.md#script-filters-and-transforms). | Subscriptions persist to `~/.hermes/webhook_subscriptions.json` and are hot-reloaded by the webhook adapter without a gateway restart. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md index 5c6a85144ff1..a1191cfbd5f5 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md @@ -477,6 +477,7 @@ hermes webhook subscribe [options] | `--deliver-chat-id` | 跨平台投递的目标聊天/频道 ID。 | | `--secret` | 自定义 HMAC 密钥。省略时自动生成。 | | `--deliver-only` | 跳过 agent——将渲染后的 `--prompt` 作为字面消息投递。零 LLM 成本,亚秒级投递。要求 `--deliver` 为真实目标(非 `log`)。 | +| `--script` | 位于 `~/.hermes/scripts/` 下的过滤/转换脚本。webhook payload 以 JSON 形式通过 stdin 传入;JSON stdout 会替换 payload,空 stdout、`[SILENT]` 或非零退出码会忽略该 webhook。参见[脚本过滤与转换](../user-guide/messaging/webhooks.md#script-filters-and-transforms)。 | 订阅持久化到 `~/.hermes/webhook_subscriptions.json`,webhook 适配器无需重启 gateway 即可热重载。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/webhooks.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/webhooks.md index 491bd3f8995e..82597095fa18 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/webhooks.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/webhooks.md @@ -81,6 +81,8 @@ curl http://localhost:8644/health | `events` | 否 | 要接受的事件类型列表(例如 `["pull_request"]`)。若为空,则接受所有事件。事件类型从 `X-GitHub-Event`、`X-GitLab-Event` 或 payload 中的 `event_type` 读取。 | | `secret` | **是** | 用于签名验证的 HMAC secret。若路由未设置,则回退到全局 `secret`。仅用于测试时可设为 `"INSECURE_NO_AUTH"`(跳过验证)。 | | `prompt` | 否 | 使用点号表示法访问 payload 字段的模板字符串(例如 `{pull_request.title}`)。若省略,则将完整 JSON payload 转储到 prompt 中。 | +| `filters` | 否 | 声明式 payload 过滤器,在认证/请求体/事件过滤之后、agent 或直接投递之前求值。不匹配时返回 `{"status":"ignored","reason":"filter"}`(HTTP 200)。 | +| `script` | 否 | 位于 `~/.hermes/scripts/` 下的过滤/转换脚本。webhook payload 以 JSON 形式通过 stdin 传入。stdout 为 JSON 对象时会在模板渲染前替换 payload;文本 stdout 以 `script_output` 形式暴露;空 stdout、`[SILENT]` 或非零退出码会忽略该 webhook。 | | `skills` | 否 | agent 运行时加载的 skill 名称列表。 | | `deliver` | 否 | 响应发送目标:`github_comment`、`telegram`、`discord`、`slack`、`signal`、`sms`、`whatsapp`、`matrix`、`mattermost`、`homeassistant`、`email`、`dingtalk`、`feishu`、`wecom`、`weixin`、`bluebubbles`、`qqbot`,或 `log`(默认)。 | | `deliver_extra` | 否 | 额外的投递配置——键取决于 `deliver` 类型(例如 `repo`、`pr_number`、`chat_id`)。值支持与 `prompt` 相同的 `{dot.notation}` 模板语法。 | @@ -116,9 +118,75 @@ platforms: events: ["push"] secret: "deploy-secret" prompt: "New push to {repository.full_name} branch {ref}: {head_commit.message}" + filters: + - field: "ref" + equals: "refs/heads/main" deliver: "telegram" ``` +### Payload 过滤器 {#payload-filters} + +当服务商发送宽泛的事件流、但只有部分 payload 应唤醒 agent 或触发 `deliver_only` 投递时,使用 `filters`。过滤器在签名验证、请求体解析和 `events` 之后运行,但在 prompt 渲染、幂等去重、agent 调度或直接投递之前运行。 + +```yaml +platforms: + webhook: + extra: + routes: + todoist: + events: ["item:updated"] + secret: "todoist-secret" + filters: + - field: "payload.labels" + contains: "hermes" + - any: + - field: "payload.priority" + equals: 4 + - field: "payload.project_id" + in_file: "~/.hermes/data/todoist/watchlist.json" + prompt: "Todoist task changed: {payload.content}" +``` + +支持的操作符: + +- `exists: true|false` +- `missing: true` +- `equals` / `not_equals` +- `contains` — 适用于字符串、列表和 dict 键 +- `in` — 内联列表 +- `in_file` — JSON 数组、JSON 对象(使用其键)或按行分隔的文本文件 +- `regex` +- `all`、`any` 和 `not` 分组 + +字段路径使用点号表示法。当存在顶层 `payload` 对象时,`payload.foo` 从中读取;对扁平 payload 则从 webhook 请求体根部读取。`event` / `event_type` 匹配解析出的事件类型,`headers.` 读取请求头。 + +### 脚本过滤与转换 {#script-filters-and-transforms} + +当声明式过滤器不够用时,使用 `script`。脚本必须位于当前 profile 的 `~/.hermes/scripts/` 目录下;相对路径在该目录内解析,且禁止路径穿越到目录之外。`.sh` 和 `.bash` 脚本用 bash 运行,其他扩展名用当前 Python 解释器运行。 + +路由 payload 以 JSON 形式发送到 stdin: + +```python +# ~/.hermes/scripts/todoist-hermes-label.py +import json +import sys + +payload = json.load(sys.stdin) +labels = payload.get("payload", {}).get("labels", []) +if "hermes" not in labels: + print("[SILENT]") + raise SystemExit(0) + +payload["body"] = payload["payload"]["content"] +print(json.dumps(payload)) +``` + +脚本结果: + +- stdout 为 JSON 对象时,替换 `prompt` 和 `deliver_extra` 使用的 payload。 +- 非 JSON 文本 stdout 会以 `script_output` 字段加入 payload。 +- 空 stdout、精确的 `[SILENT]`、`{"__hermes_ignore__": true}`、超时、脚本缺失或非零退出码,均返回 HTTP 200 及 `{"status":"ignored","reason":"script"}`。 + ### Prompt 模板 Prompt 使用点号表示法访问 webhook payload 中的嵌套字段: From 62ada5175c2ad6e4aa55986286ab8f06804cfabc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:30:47 -0700 Subject: [PATCH 236/610] feat(xai): add grok-4.5 (GA) to model catalog, context lengths, and reasoning-effort allowlist (#60887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(xai): add grok-4.5 (early access) to catalog, context lengths, and reasoning-effort allowlist - hermes_cli/models.py: grok-4.5 in _XAI_CURATED_EXTRAS (callable but absent from models.dev) and _XAI_STATIC_FALLBACK, so the /model picker and validation surface it on both xai and xai-oauth. - agent/model_metadata.py: context lengths grok-4.5 -> 500K (per model card) and grok-build-latest -> 500K (alias); grok-4.5 added to _GROK_EFFORT_CAPABLE_PREFIXES. Verified live against api.x.ai /v1/responses (2026-07-08): effort low/medium/high accepted (server default: high), "none" rejected, function calling works, full agent turn with terminal tool succeeded. * feat(xai): grok-4.5 GA — add aggregator catalog entries, refresh comments grok-4.5 is now GA: models.dev lists it (500K context, effort low/medium/high) and both OpenRouter and Nous serve x-ai/grok-4.5. Add it to the OpenRouter fallback snapshot and the Nous static list, and update the early-access comments. * chore: regenerate model-catalog.json for x-ai/grok-4.5 --- agent/model_metadata.py | 7 +++++++ hermes_cli/models.py | 4 ++++ website/static/api/model-catalog.json | 9 ++++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index c9a7d5771a85..88fab0902513 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -289,11 +289,13 @@ DEFAULT_CONTEXT_LENGTHS = { # Premium+); /v1/responses additionally enforces a ~262144 input+output # budget, but the usable context (what we track here) is 200k. "grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI) + "grok-build-latest": 500000, # alias of grok-4.5 (early access) "grok-build": 256000, # grok-build-0.1 "grok-code-fast": 256000, # grok-code-fast-1 "grok-2-vision": 8192, # grok-2-vision, -1212, -latest "grok-4-fast": 2000000, # grok-4-fast-(non-)reasoning, also matches -reasoning "grok-4.20": 2000000, # grok-4.20-0309-(non-)reasoning, -multi-agent-0309 + "grok-4.5": 500000, # grok-4.5, grok-4.5-latest — 500K context per docs.x.ai "grok-4.3": 1000000, # grok-4.3, grok-4.3-latest — 1M context per docs.x.ai "grok-4": 256000, # grok-4, grok-4-0709 "grok-3": 131072, # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast @@ -347,6 +349,11 @@ _GROK_EFFORT_CAPABLE_PREFIXES = ( "grok-3-mini", "grok-4.20-multi-agent", "grok-4.3", + # grok-4.5: verified live against /v1/responses 2026-07-08 — accepts + # effort low/medium/high (default: high when omitted) but REJECTS + # "none" ("This model does not support `reasoning_effort` value `none`"), + # unlike grok-4.3. models.dev agrees: effort values [low, medium, high]. + "grok-4.5", ) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index e19b99d4b7ca..a103be30ac58 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -48,6 +48,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ ("google/gemini-3.1-pro-preview", ""), ("google/gemini-3.5-flash", ""), # xAI + ("x-ai/grok-4.5", ""), ("x-ai/grok-4.3", ""), # DeepSeek ("deepseek/deepseek-v4-pro", ""), @@ -113,6 +114,7 @@ def _codex_curated_models() -> list[str]: # grok-4-1-fast{,-reasoning,-non-reasoning}, grok-code-fast-1 → grok-4.3). _XAI_STATIC_FALLBACK: list[str] = [ "grok-build-0.1", + "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", @@ -121,6 +123,7 @@ _XAI_STATIC_FALLBACK: list[str] = [ # Callable via xAI OAuth but omitted from models.dev and /v1/models listings. _XAI_CURATED_EXTRAS: list[str] = [ + "grok-4.5", # GA 2026-07 — kept until the models.dev disk cache refreshes "grok-composer-2.5-fast", ] @@ -191,6 +194,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "google/gemini-3.1-pro-preview", "google/gemini-3.5-flash", # xAI + "x-ai/grok-4.5", "x-ai/grok-4.3", # DeepSeek "deepseek/deepseek-v4-pro", diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index d69dc3af4afa..96009ade3169 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-07-08T13:38:17Z", + "updated_at": "2026-07-08T18:59:16Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" @@ -56,6 +56,10 @@ "id": "google/gemini-3.5-flash", "description": "" }, + { + "id": "x-ai/grok-4.5", + "description": "" + }, { "id": "x-ai/grok-4.3", "description": "" @@ -186,6 +190,9 @@ { "id": "google/gemini-3.5-flash" }, + { + "id": "x-ai/grok-4.5" + }, { "id": "x-ai/grok-4.3" }, From d6a275b735d7bd90472a193f35bc11888fa007ef Mon Sep 17 00:00:00 2001 From: nullptr0807 Date: Wed, 8 Jul 2026 14:17:18 +0000 Subject: [PATCH 237/610] fix(gateway): compact hygiene transcripts in place --- gateway/run.py | 45 +++++++--- tests/gateway/test_session_hygiene.py | 120 ++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 14 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index e71fe26ba8ea..4370a084eb17 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -11107,6 +11107,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ] if len(_hyg_msgs) >= 4: + _hyg_session_db = getattr(self._session_db, "_db", self._session_db) _hyg_agent = AIAgent( **_hyg_runtime, model=_hyg_model, @@ -11115,15 +11116,31 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew skip_memory=True, enabled_toolsets=["memory"], session_id=session_entry.session_id, - session_db=getattr(self._session_db, "_db", self._session_db), + session_db=_hyg_session_db, ) try: - # The hygiene agent rotates the session - # forward to a continuation id that becomes - # the gateway session's live row. It must - # never finalize on close() — close() would - # end the newly rotated session the gateway - # entry now points at. + # Gateway hygiene runs before the user turn + # starts and already owns the session binding. + # Prefer in-place compaction here: it archives + # old rows under the same session id instead of + # minting a continuation child that then has to + # be published back to SessionStore/topic + # bindings. If no SessionDB is available, + # compress_context leaves this flag false and + # the guard below preserves the transcript. + _hyg_agent.compression_in_place = True + _bind_hyg_state = getattr( + getattr(_hyg_agent, "context_compressor", None), + "bind_session_state", + None, + ) + if callable(_bind_hyg_state): + _bind_hyg_state( + _hyg_session_db, + session_entry.session_id, + ) + # It must never finalize on close() — close() + # would end the live gateway session row. _hyg_agent._end_session_on_close = False _hyg_agent._print_fn = lambda *a, **kw: None @@ -11157,13 +11174,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # Only rewrite the transcript when rotation produced # a NEW session id OR in-place compaction succeeded. # The danger this guards against (mirrors the - # /compress fix #44794/#39704): the hygiene agent is - # built WITHOUT a session_db, so _compress_context - # cannot rotate — if it also wasn't in-place, the - # session_id is unchanged for a FAILURE reason, and an - # unconditional rewrite_transcript() would DELETE the - # original messages and replace them with only the - # compressed summary (permanent data loss, #21301). + # /compress fix #44794/#39704): if _compress_context + # returns a summary but neither rotates nor completes + # archive_and_compact(), the session_id is unchanged + # for a FAILURE reason, and an unconditional + # rewrite_transcript() would DELETE the original + # messages and replace them with only the compressed + # summary (permanent data loss, #21301). if _hyg_rotated or _hyg_in_place: self.session_store.rewrite_transcript( session_entry.session_id, _compressed diff --git a/tests/gateway/test_session_hygiene.py b/tests/gateway/test_session_hygiene.py index 10bcf00cedec..abad2b49ef74 100644 --- a/tests/gateway/test_session_hygiene.py +++ b/tests/gateway/test_session_hygiene.py @@ -836,6 +836,126 @@ async def test_session_hygiene_informs_user_when_aux_model_fails_but_recovers(mo FakeCompressAgentWithAuxRecovery.last_instance.close.assert_called_once() +@pytest.mark.asyncio +async def test_session_hygiene_forces_in_place_compaction_with_bound_session_db( + monkeypatch, tmp_path +): + """Regression for #60947: gateway hygiene should not rely on + helper-agent session rotation to shrink a live gateway transcript. + + The hygiene pass runs before the user turn and already owns the gateway + session binding, so it should force in-place compaction and bind the + compressor to the gateway SessionDB. Otherwise a helper can return a + summary without rotating/compacting, the guard preserves the original + transcript, and the same oversized session is reloaded on every turn. + """ + fake_dotenv = types.ModuleType("dotenv") + fake_dotenv.load_dotenv = lambda *args, **kwargs: None + monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv) + + fake_db = object() + + class FakeInPlaceCompressAgent: + last_instance = None + + def __init__(self, **kwargs): + self.model = kwargs.get("model") + self.session_id = kwargs.get("session_id", "fake-session") + self._session_db = kwargs.get("session_db") + self.compression_in_place = False + self._last_compaction_in_place = False + self.context_compressor = SimpleNamespace( + bind_session_state=MagicMock(), + _last_compress_aborted=False, + _last_aux_model_failure_model=None, + ) + self._print_fn = None + self.shutdown_memory_provider = MagicMock() + self.close = MagicMock() + type(self).last_instance = self + + def _compress_context(self, messages, *_args, **_kwargs): + assert self.compression_in_place is True + assert self._session_db is fake_db + self._last_compaction_in_place = True + return ([{"role": "assistant", "content": "compressed in place"}], None) + + fake_run_agent = types.ModuleType("run_agent") + fake_run_agent.AIAgent = FakeInPlaceCompressAgent + monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) + + gateway_run = importlib.import_module("gateway.run") + GatewayRunner = gateway_run.GatewayRunner + + adapter = HygieneCaptureAdapter() + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake-token")} + ) + runner.adapters = {Platform.TELEGRAM: adapter} + runner._voice_mode = {} + runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False) + runner.session_store = MagicMock() + runner.session_store.get_or_create_session.return_value = SessionEntry( + session_key="agent:main:telegram:private:12345", + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="private", + ) + runner.session_store.load_transcript.return_value = _make_history(12, content_size=400) + runner.session_store.has_any_sessions.return_value = True + runner.session_store.rewrite_transcript = MagicMock() + runner.session_store.append_to_transcript = MagicMock() + runner._running_agents = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._session_db = SimpleNamespace(_db=fake_db) + runner._is_user_authorized = lambda _source: True + runner._set_session_env = lambda _context: None + runner._run_agent = AsyncMock( + return_value={ + "final_response": "ok", + "messages": [], + "tools": [], + "history_offset": 0, + "last_prompt_tokens": 0, + } + ) + + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"} + ) + monkeypatch.setattr( + "agent.model_metadata.get_model_context_length", + lambda *_args, **_kwargs: 100, + ) + + event = MessageEvent( + text="hello", + source=SessionSource( + platform=Platform.TELEGRAM, + chat_id="12345", + chat_type="private", + user_id="12345", + ), + message_id="1", + ) + + result = await runner._handle_message(event) + + assert result == "ok" + agent = FakeInPlaceCompressAgent.last_instance + assert agent is not None + agent.context_compressor.bind_session_state.assert_called_once_with(fake_db, "sess-1") + runner.session_store.rewrite_transcript.assert_called_once_with( + "sess-1", [{"role": "assistant", "content": "compressed in place"}] + ) + runner._run_agent.assert_awaited_once() + + @pytest.mark.asyncio async def test_session_hygiene_honors_configurable_hard_message_limit( monkeypatch, tmp_path From 4d611ba0c3b1f09b8092674a2227254ce8e9d89c Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:15:55 -0700 Subject: [PATCH 238/610] chore: AUTHOR_MAP entry for nullptr0807 (PR #60956 salvage) --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index a73747398f01..9e1b4ec54fcc 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -297,6 +297,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "iamgexin@qq.com": "nullptr0807", # PR #60956 salvage (gateway hygiene in-place compaction; #60947) "caztronics@yahoo.com": "doncazper", "30668368+alex107ivanov@users.noreply.github.com": "alex107ivanov", "210088133+rungmc357@users.noreply.github.com": "rungmc357", From 9a4341aa90ececee7027401974bfcab474be5db1 Mon Sep 17 00:00:00 2001 From: yoma Date: Sat, 4 Jul 2026 19:30:10 +0800 Subject: [PATCH 239/610] fix(dashboard): keep status responsive when session db locks (cherry picked from commit a3454dd15dd7440e4bf16b3ad45ef43083ea4f2f) --- hermes_cli/web_server.py | 58 +++++++++++++++++++++-------- tests/hermes_cli/test_web_server.py | 38 +++++++++++++++++++ 2 files changed, 80 insertions(+), 16 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 69d40bc47de6..00e487ff6647 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1120,6 +1120,8 @@ except (ValueError, TypeError): ) _GATEWAY_HEALTH_TIMEOUT = 3.0 +_STATUS_ACTIVE_SESSIONS_TIMEOUT = 0.75 + # DEPRECATED (scheduled for removal): GATEWAY_HEALTH_URL / GATEWAY_HEALTH_TIMEOUT. # Cross-container / cross-host gateway liveness detection will be folded into a # first-class dashboard config key so it's no longer Docker-adjacent lore buried @@ -1170,6 +1172,45 @@ def _probe_gateway_health() -> tuple[bool, dict | None]: return False, None +def _count_status_active_sessions() -> int: + """Return the dashboard status active-session count. + + This is best-effort status garnish, not a critical path. Use a read-only + connection so /api/status never tries to initialise or migrate state.db + while another Hermes process is writing to it. + """ + from hermes_state import SessionDB + + db = SessionDB(read_only=True) + try: + sessions = db.list_sessions_rich(limit=50) + now = time.time() + return sum( + 1 for s in sessions + if s.get("ended_at") is None + and (now - s.get("last_active", s.get("started_at", 0))) < 300 + ) + finally: + db.close() + + +async def _status_active_sessions() -> int: + loop = asyncio.get_running_loop() + try: + return await asyncio.wait_for( + loop.run_in_executor(None, _count_status_active_sessions), + timeout=_STATUS_ACTIVE_SESSIONS_TIMEOUT, + ) + except asyncio.TimeoutError: + _log.debug( + "/api/status active session count exceeded %.2fs; returning 0", + _STATUS_ACTIVE_SESSIONS_TIMEOUT, + ) + except Exception as exc: + _log.debug("/api/status active session count unavailable: %s", exc) + return 0 + + # Image MIME types this endpoint will serve. Extension-allowlisted so an # authenticated caller can't pull non-image files through it. _MEDIA_CONTENT_TYPES = { @@ -2485,22 +2526,7 @@ async def get_status(profile: Optional[str] = None): if gateway_running and gateway_state is None and remote_health_body is not None: gateway_state = "running" - active_sessions = 0 - try: - from hermes_state import SessionDB - db = SessionDB() - try: - sessions = db.list_sessions_rich(limit=50) - now = time.time() - active_sessions = sum( - 1 for s in sessions - if s.get("ended_at") is None - and (now - s.get("last_active", s.get("started_at", 0))) < 300 - ) - finally: - db.close() - except Exception: - pass + active_sessions = await _status_active_sessions() # Busy/drainable readout (NAS lifecycle-safety gate). active_agents is # the in-flight gateway-turn count the gateway now persists at every diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index ae732bcaba26..68793799487f 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -267,6 +267,44 @@ class TestWebServerEndpoints: assert "active_sessions" in data assert data["can_update_hermes"] is True + def test_status_active_session_count_uses_read_only_db(self, monkeypatch): + import hermes_cli.web_server as web_server + + captured = {} + + class _FakeDB: + def __init__(self, *args, **kwargs): + captured["read_only"] = kwargs.get("read_only") + + def list_sessions_rich(self, limit): + captured["limit"] = limit + return [ + {"ended_at": None, "last_active": 95}, + {"ended_at": 99, "last_active": 99}, + {"ended_at": None, "last_active": -300}, + ] + + def close(self): + captured["closed"] = True + + monkeypatch.setattr("hermes_state.SessionDB", _FakeDB) + monkeypatch.setattr(web_server.time, "time", lambda: 100) + + assert web_server._count_status_active_sessions() == 1 + assert captured == {"read_only": True, "limit": 50, "closed": True} + + def test_get_status_degrades_when_active_session_count_fails(self, monkeypatch): + import hermes_cli.web_server as web_server + + def _locked_count(): + raise TimeoutError("database is locked") + + monkeypatch.setattr(web_server, "_count_status_active_sessions", _locked_count) + + resp = self.client.get("/api/status") + assert resp.status_code == 200 + assert resp.json()["active_sessions"] == 0 + def test_gateway_drain_begin_writes_marker(self): from gateway import drain_control From 346e5673de8dd2e2ccc11817c81323dbb16e78f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E7=B6=A0BG?= Date: Sat, 13 Jun 2026 17:02:35 +0800 Subject: [PATCH 240/610] =?UTF-8?q?=F0=9F=90=9B=20fix(dashboard):=20offloa?= =?UTF-8?q?d=20cron=20profile=20scans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 1fded709aaf79ba426ab360ac785fcdfae8229f0) --- hermes_cli/web_server.py | 77 ++++++++++++++----- .../test_web_server_cron_profiles.py | 43 +++++++++++ 2 files changed, 102 insertions(+), 18 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 00e487ff6647..00931549fe34 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -9985,8 +9985,7 @@ def _find_cron_job_profile(job_id: str) -> Optional[str]: return None -@app.get("/api/cron/jobs") -async def list_cron_jobs(profile: str = "all"): +def _list_cron_jobs_sync(profile: str = "all"): requested = (profile or "all").strip() if requested.lower() != "all": return _call_cron_for_profile(requested, "list_jobs", True) @@ -10003,8 +10002,17 @@ async def list_cron_jobs(profile: str = "all"): return jobs -@app.get("/api/cron/jobs/{job_id}") -async def get_cron_job(job_id: str, profile: Optional[str] = None): +async def _run_cron_dashboard_io(func, *args, **kwargs): + """Run cron dashboard profile/job I/O outside the FastAPI event loop.""" + return await asyncio.to_thread(func, *args, **kwargs) + + +@app.get("/api/cron/jobs") +async def list_cron_jobs(profile: str = "all"): + return await _run_cron_dashboard_io(_list_cron_jobs_sync, profile) + + +def _get_cron_job_sync(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") @@ -10014,8 +10022,12 @@ async def get_cron_job(job_id: str, profile: Optional[str] = None): return job -@app.get("/api/cron/jobs/{job_id}/runs") -async def list_cron_job_runs(job_id: str, profile: Optional[str] = None, limit: int = 20): +@app.get("/api/cron/jobs/{job_id}") +async def get_cron_job(job_id: str, profile: Optional[str] = None): + return await _run_cron_dashboard_io(_get_cron_job_sync, job_id, profile) + + +def _list_cron_job_runs_sync(job_id: str, profile: Optional[str] = None, limit: int = 20): """Run sessions produced by a cron job, newest first. Cron runs are stored as ordinary sessions whose id is @@ -10060,8 +10072,12 @@ async def list_cron_job_runs(job_id: str, profile: Optional[str] = None, limit: db.close() -@app.post("/api/cron/jobs") -async def create_cron_job(body: CronJobCreate, profile: str = "default"): +@app.get("/api/cron/jobs/{job_id}/runs") +async def list_cron_job_runs(job_id: str, profile: Optional[str] = None, limit: int = 20): + return await _run_cron_dashboard_io(_list_cron_job_runs_sync, job_id, profile, limit) + + +def _create_cron_job_sync(body: CronJobCreate, profile: str = "default"): try: profile_name, profile_home = _cron_profile_home(profile) script = _normalize_dashboard_cron_script(body.script, profile_home) @@ -10099,6 +10115,11 @@ async def create_cron_job(body: CronJobCreate, profile: str = "default"): raise HTTPException(status_code=400, detail=str(e)) +@app.post("/api/cron/jobs") +async def create_cron_job(body: CronJobCreate, profile: str = "default"): + return await _run_cron_dashboard_io(_create_cron_job_sync, body, profile) + + @app.get("/api/cron/delivery-targets") async def get_cron_delivery_targets(): """Delivery targets the cron dropdown should offer. @@ -10127,8 +10148,7 @@ async def get_cron_delivery_targets(): return {"targets": targets} -@app.put("/api/cron/jobs/{job_id}") -async def update_cron_job(job_id: str, body: CronJobUpdate, profile: Optional[str] = None): +def _update_cron_job_sync(job_id: str, body: CronJobUpdate, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") @@ -10162,8 +10182,12 @@ async def update_cron_job(job_id: str, body: CronJobUpdate, profile: Optional[st return job -@app.post("/api/cron/jobs/{job_id}/pause") -async def pause_cron_job(job_id: str, profile: Optional[str] = None): +@app.put("/api/cron/jobs/{job_id}") +async def update_cron_job(job_id: str, body: CronJobUpdate, profile: Optional[str] = None): + return await _run_cron_dashboard_io(_update_cron_job_sync, job_id, body, profile) + + +def _pause_cron_job_sync(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") @@ -10173,8 +10197,12 @@ async def pause_cron_job(job_id: str, profile: Optional[str] = None): return job -@app.post("/api/cron/jobs/{job_id}/resume") -async def resume_cron_job(job_id: str, profile: Optional[str] = None): +@app.post("/api/cron/jobs/{job_id}/pause") +async def pause_cron_job(job_id: str, profile: Optional[str] = None): + return await _run_cron_dashboard_io(_pause_cron_job_sync, job_id, profile) + + +def _resume_cron_job_sync(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") @@ -10184,8 +10212,12 @@ async def resume_cron_job(job_id: str, profile: Optional[str] = None): return job -@app.post("/api/cron/jobs/{job_id}/trigger") -async def trigger_cron_job(job_id: str, profile: Optional[str] = None): +@app.post("/api/cron/jobs/{job_id}/resume") +async def resume_cron_job(job_id: str, profile: Optional[str] = None): + return await _run_cron_dashboard_io(_resume_cron_job_sync, job_id, profile) + + +def _trigger_cron_job_sync(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") @@ -10195,8 +10227,12 @@ async def trigger_cron_job(job_id: str, profile: Optional[str] = None): return job -@app.delete("/api/cron/jobs/{job_id}") -async def delete_cron_job(job_id: str, profile: Optional[str] = None): +@app.post("/api/cron/jobs/{job_id}/trigger") +async def trigger_cron_job(job_id: str, profile: Optional[str] = None): + return await _run_cron_dashboard_io(_trigger_cron_job_sync, job_id, profile) + + +def _delete_cron_job_sync(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") @@ -10209,6 +10245,11 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None): return {"ok": True} +@app.delete("/api/cron/jobs/{job_id}") +async def delete_cron_job(job_id: str, profile: Optional[str] = None): + return await _run_cron_dashboard_io(_delete_cron_job_sync, job_id, profile) + + def _fire_cron_job_for_profile(profile: str, job_id: str) -> bool: """Run ONE due cron job end-to-end for ``profile`` via the resolved scheduler provider's ``fire_due`` (store CAS claim + ``run_one_job``). diff --git a/tests/hermes_cli/test_web_server_cron_profiles.py b/tests/hermes_cli/test_web_server_cron_profiles.py index f8fa1e008a5f..0e6a268326ed 100644 --- a/tests/hermes_cli/test_web_server_cron_profiles.py +++ b/tests/hermes_cli/test_web_server_cron_profiles.py @@ -1,5 +1,7 @@ """Regression tests for dashboard cron job profile routing.""" +import threading + import pytest from fastapi import HTTPException @@ -159,6 +161,47 @@ async def test_cron_mutation_without_profile_finds_named_profile_job(isolated_pr assert worker_jobs[0]["enabled"] is False +@pytest.mark.asyncio +async def test_cron_profile_scan_runs_off_event_loop(isolated_profiles, monkeypatch): + from hermes_cli import web_server + + worker_job = web_server._call_cron_for_profile( + "worker_alpha", + "create_job", + prompt="managed by named profile", + schedule="every 1h", + name="thread-offload-job", + ) + + event_loop_thread = threading.get_ident() + profile_scan_threads = [] + worker_threads = [] + original_profile_dicts = web_server._cron_profile_dicts + original_find = web_server._find_cron_job_profile + + def tracking_profile_dicts(): + profile_scan_threads.append(threading.get_ident()) + return original_profile_dicts() + + def tracking_find(job_id): + worker_threads.append(threading.get_ident()) + return original_find(job_id) + + monkeypatch.setattr(web_server, "_cron_profile_dicts", tracking_profile_dicts) + monkeypatch.setattr(web_server, "_find_cron_job_profile", tracking_find) + + jobs = await web_server.list_cron_jobs(profile="all") + paused = await web_server.pause_cron_job(worker_job["id"]) + + assert any(job["id"] == worker_job["id"] for job in jobs) + assert paused["profile"] == "worker_alpha" + assert profile_scan_threads + assert worker_threads + assert all(thread_id != event_loop_thread for thread_id in profile_scan_threads) + assert all(thread_id != event_loop_thread for thread_id in worker_threads) + + + @pytest.mark.asyncio async def test_update_cron_job_normalizes_dashboard_core_fields(isolated_profiles, tmp_path): from hermes_cli import web_server From 49fa04a2356dfa10201a4e78bc0d9dc251588797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E7=B6=A0BG?= Date: Sat, 13 Jun 2026 17:19:23 +0800 Subject: [PATCH 241/610] =?UTF-8?q?=F0=9F=90=9B=20fix(dashboard):=20use=20?= =?UTF-8?q?managed=20cron=20threadpool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 97fabc289c623549310125ac3804e21afde87c43) --- hermes_cli/web_server.py | 10 ++++- .../test_web_server_cron_profiles.py | 39 +++++++++++++++---- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 00931549fe34..d75b2cba616b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -21,6 +21,7 @@ from dataclasses import dataclass from datetime import datetime, timezone import hashlib import hmac +import inspect import importlib.util import json import logging @@ -93,6 +94,7 @@ try: from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel + from starlette.concurrency import run_in_threadpool except ImportError: # First try lazy-installing the dashboard extras. Only the user actually # running `hermes dashboard` needs fastapi+uvicorn; lazy install keeps @@ -108,6 +110,7 @@ except ImportError: from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel + from starlette.concurrency import run_in_threadpool except Exception: raise SystemExit( "Web UI requires fastapi and uvicorn.\n" @@ -10004,7 +10007,12 @@ def _list_cron_jobs_sync(profile: str = "all"): async def _run_cron_dashboard_io(func, *args, **kwargs): """Run cron dashboard profile/job I/O outside the FastAPI event loop.""" - return await asyncio.to_thread(func, *args, **kwargs) + if inspect.iscoroutinefunction(func): + raise TypeError("_run_cron_dashboard_io only accepts sync callables") + result = await run_in_threadpool(func, *args, **kwargs) + if inspect.isawaitable(result): + raise TypeError("_run_cron_dashboard_io sync callable returned an awaitable") + return result @app.get("/api/cron/jobs") diff --git a/tests/hermes_cli/test_web_server_cron_profiles.py b/tests/hermes_cli/test_web_server_cron_profiles.py index 0e6a268326ed..185b7bac32a5 100644 --- a/tests/hermes_cli/test_web_server_cron_profiles.py +++ b/tests/hermes_cli/test_web_server_cron_profiles.py @@ -1,5 +1,6 @@ """Regression tests for dashboard cron job profile routing.""" +from queue import Empty, SimpleQueue import threading import pytest @@ -24,6 +25,15 @@ def isolated_profiles(tmp_path, monkeypatch): return {"default": default_home, "worker_alpha": worker_home} +def _drain_queue(q): + values = [] + while True: + try: + values.append(q.get_nowait()) + except Empty: + return values + + def test_call_cron_for_profile_routes_storage_and_restores_globals(isolated_profiles): from cron import jobs as cron_jobs from hermes_cli import web_server @@ -174,17 +184,17 @@ async def test_cron_profile_scan_runs_off_event_loop(isolated_profiles, monkeypa ) event_loop_thread = threading.get_ident() - profile_scan_threads = [] - worker_threads = [] + profile_scan_threads = SimpleQueue() + worker_threads = SimpleQueue() original_profile_dicts = web_server._cron_profile_dicts original_find = web_server._find_cron_job_profile def tracking_profile_dicts(): - profile_scan_threads.append(threading.get_ident()) + profile_scan_threads.put(threading.get_ident()) return original_profile_dicts() def tracking_find(job_id): - worker_threads.append(threading.get_ident()) + worker_threads.put(threading.get_ident()) return original_find(job_id) monkeypatch.setattr(web_server, "_cron_profile_dicts", tracking_profile_dicts) @@ -195,10 +205,23 @@ async def test_cron_profile_scan_runs_off_event_loop(isolated_profiles, monkeypa assert any(job["id"] == worker_job["id"] for job in jobs) assert paused["profile"] == "worker_alpha" - assert profile_scan_threads - assert worker_threads - assert all(thread_id != event_loop_thread for thread_id in profile_scan_threads) - assert all(thread_id != event_loop_thread for thread_id in worker_threads) + profile_scan_thread_ids = _drain_queue(profile_scan_threads) + worker_thread_ids = _drain_queue(worker_threads) + assert profile_scan_thread_ids + assert worker_thread_ids + assert all(thread_id != event_loop_thread for thread_id in profile_scan_thread_ids) + assert all(thread_id != event_loop_thread for thread_id in worker_thread_ids) + + +@pytest.mark.asyncio +async def test_cron_dashboard_io_rejects_async_callables(): + from hermes_cli import web_server + + async def async_callable(): + return "nope" + + with pytest.raises(TypeError, match="only accepts sync callables"): + await web_server._run_cron_dashboard_io(async_callable) From 7d0ddbb2ff50419577d7671ad21ae0449ec9c32b Mon Sep 17 00:00:00 2001 From: 0disoft Date: Sat, 27 Jun 2026 17:43:26 +0900 Subject: [PATCH 242/610] fix(dashboard): cache gateway PID status probes (cherry picked from commit a2bbe564adf7a2c707a4d2e3db306a11e97f0690) --- gateway/status.py | 85 +++++++++++++++++++++++++++++ hermes_cli/web_server.py | 3 +- tests/gateway/test_status.py | 65 ++++++++++++++++++++++ tests/hermes_cli/test_web_server.py | 44 ++++++++++----- 4 files changed, 182 insertions(+), 15 deletions(-) diff --git a/gateway/status.py b/gateway/status.py index d041193fdf09..7b8e9ff57583 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -18,6 +18,8 @@ import shlex import signal import subprocess import sys +import threading +import time from datetime import datetime, timezone from pathlib import Path from hermes_constants import get_hermes_home, _get_platform_default_hermes_home @@ -40,6 +42,9 @@ _gateway_lock_handle = None # past the JSON payload so runtime status / PID readers can still read the file # while another process holds the mutual-exclusion lock. _WINDOWS_LOCK_OFFSET = 1024 * 1024 +_GATEWAY_RUNNING_PID_CACHE_TTL_SECONDS = 1.0 +_gateway_running_pid_cache_lock = threading.Lock() +_gateway_running_pid_cache: dict[tuple[str, bool, bool], tuple[float, tuple[Any, ...], Optional[int]]] = {} def _get_process_hermes_home() -> Path: @@ -496,6 +501,33 @@ def _pid_from_record(record: Optional[dict[str, Any]]) -> Optional[int]: return None +def _clear_running_pid_cache() -> None: + with _gateway_running_pid_cache_lock: + _gateway_running_pid_cache.clear() + + +def _file_cache_signature(path: Path) -> tuple[bool, Optional[int], Optional[int]]: + try: + st = path.stat() + except OSError: + return (False, None, None) + return (True, st.st_mtime_ns, st.st_size) + + +def _running_pid_cache_signature( + pid_path: Path, + *, + include_runtime_status: bool, +) -> tuple[Any, ...]: + parts: list[Any] = [ + _file_cache_signature(pid_path), + _file_cache_signature(_get_gateway_lock_path(pid_path)), + ] + if include_runtime_status: + parts.append(_file_cache_signature(_get_runtime_status_path())) + return tuple(parts) + + def _cleanup_invalid_pid_path(pid_path: Path, *, cleanup_stale: bool) -> None: """Delete a stale gateway PID file (and its sibling lock metadata). @@ -508,6 +540,7 @@ def _cleanup_invalid_pid_path(pid_path: Path, *, cleanup_stale: bool) -> None: """ if not cleanup_stale: return + _clear_running_pid_cache() try: pid_path.unlink(missing_ok=True) except Exception: @@ -693,6 +726,7 @@ def acquire_gateway_runtime_lock() -> bool: return False _write_gateway_lock_record(handle) _gateway_lock_handle = handle + _clear_running_pid_cache() return True @@ -708,6 +742,7 @@ def release_gateway_runtime_lock() -> None: handle.close() except OSError: pass + _clear_running_pid_cache() def is_gateway_runtime_lock_active(lock_path: Optional[Path] = None) -> bool: @@ -750,6 +785,7 @@ def write_pid_file() -> None: try: with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(record) + _clear_running_pid_cache() except Exception: try: path.unlink(missing_ok=True) @@ -942,6 +978,7 @@ def remove_pid_file() -> None: # PID file belongs to a different process — leave it alone. return path.unlink(missing_ok=True) + _clear_running_pid_cache() except Exception: pass @@ -1447,6 +1484,54 @@ def get_running_pid( return None +def get_running_pid_cached( + pid_path: Optional[Path] = None, + *, + cleanup_stale: bool = True, + ttl_seconds: float = _GATEWAY_RUNNING_PID_CACHE_TTL_SECONDS, +) -> Optional[int]: + """Cached read-side wrapper for dashboard/status polling. + + ``get_running_pid()`` probes the runtime lock by briefly opening and locking + ``gateway.lock``. That is the right authoritative check for control paths, + but high-frequency read-only HTTP polling can call it hundreds of times per + minute. Cache for a short window and invalidate on PID/lock/runtime-status + file changes so status endpoints do not churn file descriptors while still + noticing gateway start/stop transitions quickly. + """ + if ttl_seconds <= 0: + return get_running_pid(pid_path, cleanup_stale=cleanup_stale) + + resolved_pid_path = pid_path or _get_pid_path() + include_runtime_status = pid_path is None + signature = _running_pid_cache_signature( + resolved_pid_path, + include_runtime_status=include_runtime_status, + ) + key = (str(resolved_pid_path), bool(cleanup_stale), include_runtime_status) + now = time.monotonic() + + with _gateway_running_pid_cache_lock: + cached = _gateway_running_pid_cache.get(key) + if cached is not None: + cached_at, cached_signature, cached_pid = cached + if now - cached_at <= ttl_seconds and cached_signature == signature: + return cached_pid + + pid = get_running_pid(pid_path, cleanup_stale=cleanup_stale) + refreshed_signature = _running_pid_cache_signature( + resolved_pid_path, + include_runtime_status=include_runtime_status, + ) + with _gateway_running_pid_cache_lock: + _gateway_running_pid_cache[key] = ( + time.monotonic(), + refreshed_signature, + pid, + ) + return pid + + def is_gateway_running( pid_path: Optional[Path] = None, *, diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d75b2cba616b..8e43b1b17d94 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -78,6 +78,7 @@ from hermes_cli.config import ( from gateway.status import ( derive_gateway_busy, derive_gateway_drainable, + get_running_pid_cached, get_running_pid, get_runtime_status_running_pid, parse_active_agents, @@ -2456,7 +2457,7 @@ async def get_status(profile: Optional[str] = None): # Try local PID check first (same-host). If that fails and a remote # GATEWAY_HEALTH_URL is configured, probe the gateway over HTTP so the # dashboard works when the gateway runs in a separate container. - gateway_pid = get_running_pid() + gateway_pid = get_running_pid_cached() gateway_running = gateway_pid is not None remote_health_body: dict | None = None diff --git a/tests/gateway/test_status.py b/tests/gateway/test_status.py index 780bf8efc516..16ce08207731 100644 --- a/tests/gateway/test_status.py +++ b/tests/gateway/test_status.py @@ -223,6 +223,71 @@ class TestGatewayPidState: assert status.get_running_pid() == os.getpid() + def test_get_running_pid_cached_reuses_runtime_lock_probe(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + status._clear_running_pid_cache() + + pid_path = tmp_path / "gateway.pid" + record = { + "pid": os.getpid(), + "kind": "hermes-gateway", + "argv": ["python", "-m", "hermes_cli.main", "gateway"], + "start_time": 123, + } + pid_path.write_text(json.dumps(record)) + (tmp_path / "gateway.lock").write_text(json.dumps(record)) + + calls = {"lock_active": 0} + + def _lock_active(lock_path=None): + calls["lock_active"] += 1 + return True + + monkeypatch.setattr(status, "is_gateway_runtime_lock_active", _lock_active) + monkeypatch.setattr(status, "_pid_exists", lambda pid: True) + monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 123) + monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: None) + + assert status.get_running_pid_cached(ttl_seconds=60) == os.getpid() + assert status.get_running_pid_cached(ttl_seconds=60) == os.getpid() + assert calls["lock_active"] == 1 + + def test_get_running_pid_cached_invalidates_when_pid_file_changes(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + status._clear_running_pid_cache() + + pid_path = tmp_path / "gateway.pid" + + def _write_record(pid: int, start_time: int) -> None: + record = { + "pid": pid, + "kind": "hermes-gateway", + "argv": ["python", "-m", "hermes_cli.main", "gateway"], + "start_time": start_time, + } + pid_path.write_text(json.dumps(record)) + (tmp_path / "gateway.lock").write_text(json.dumps(record)) + + _write_record(111, 123) + + calls = {"lock_active": 0} + + def _lock_active(lock_path=None): + calls["lock_active"] += 1 + return True + + monkeypatch.setattr(status, "is_gateway_runtime_lock_active", _lock_active) + monkeypatch.setattr(status, "_pid_exists", lambda pid: True) + monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 123 if pid == 111 else 456) + monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: None) + + assert status.get_running_pid_cached(ttl_seconds=60) == 111 + + _write_record(2222, 456) + + assert status.get_running_pid_cached(ttl_seconds=60) == 2222 + assert calls["lock_active"] == 2 + def test_get_running_pid_cleans_stale_metadata_from_dead_foreign_pid(self, tmp_path, monkeypatch): """Stale PID file from a *different* PID (crashed process) must still be cleaned. diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 68793799487f..5daf308c6478 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -305,6 +305,22 @@ class TestWebServerEndpoints: assert resp.status_code == 200 assert resp.json()["active_sessions"] == 0 + def test_get_status_uses_cached_gateway_pid_probe(self, monkeypatch): + import hermes_cli.web_server as web_server + + calls = {"get_running_pid_cached": 0} + + def _cached_pid(): + calls["get_running_pid_cached"] += 1 + return None + + monkeypatch.setattr(web_server, "get_running_pid_cached", _cached_pid) + + resp = self.client.get("/api/status") + + assert resp.status_code == 200 + assert calls["get_running_pid_cached"] == 1 + def test_gateway_drain_begin_writes_marker(self): from gateway import drain_control @@ -1617,7 +1633,7 @@ class TestWebServerEndpoints: def get_connected_platforms(self): return [_Platform("telegram")] - monkeypatch.setattr(web_server, "get_running_pid", lambda: 1234) + monkeypatch.setattr(web_server, "get_running_pid_cached", lambda: 1234) monkeypatch.setattr( web_server, "read_runtime_status", @@ -1649,7 +1665,7 @@ class TestWebServerEndpoints: def get_connected_platforms(self): return [] - monkeypatch.setattr(web_server, "get_running_pid", lambda: None) + monkeypatch.setattr(web_server, "get_running_pid_cached", lambda: None) monkeypatch.setattr( web_server, "read_runtime_status", @@ -5057,7 +5073,7 @@ class TestStatusRemoteGateway: """When local PID check fails and remote probe succeeds, gateway shows running.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: None) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None) monkeypatch.setattr(ws, "read_runtime_status", lambda: None) monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642") monkeypatch.setattr(ws, "_probe_gateway_health", lambda: (True, { @@ -5079,7 +5095,7 @@ class TestStatusRemoteGateway: """When local PID check succeeds, the remote probe is never called.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: 1234) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234) monkeypatch.setattr(ws, "read_runtime_status", lambda: { "gateway_state": "running", "platforms": {}, @@ -5102,7 +5118,7 @@ class TestStatusRemoteGateway: """When GATEWAY_HEALTH_URL is unset, no probe is attempted.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: None) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None) monkeypatch.setattr(ws, "read_runtime_status", lambda: None) monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", None) @@ -5116,7 +5132,7 @@ class TestStatusRemoteGateway: """Remote gateway running but PID not in response — pid should be None.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: None) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None) monkeypatch.setattr(ws, "read_runtime_status", lambda: None) monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642") monkeypatch.setattr(ws, "_probe_gateway_health", lambda: (True, { @@ -5155,7 +5171,7 @@ class TestGatewayBusyReadout: """gateway_busy is True iff running AND active_agents > 0.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: 1234) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234) monkeypatch.setattr(ws, "read_runtime_status", lambda: { "gateway_state": "running", "platforms": {}, @@ -5173,7 +5189,7 @@ class TestGatewayBusyReadout: """A running gateway with zero in-flight turns is drainable, not busy.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: 1234) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234) monkeypatch.setattr(ws, "read_runtime_status", lambda: { "gateway_state": "running", "platforms": {}, @@ -5191,7 +5207,7 @@ class TestGatewayBusyReadout: gate dominates.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: 1234) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234) monkeypatch.setattr(ws, "read_runtime_status", lambda: { "gateway_state": "draining", "platforms": {}, @@ -5207,7 +5223,7 @@ class TestGatewayBusyReadout: active_agents 0 — never a spurious busy that would wedge NAS.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: None) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None) monkeypatch.setattr(ws, "read_runtime_status", lambda: None) monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", None) @@ -5223,9 +5239,9 @@ class TestGatewayBusyReadout: wins over the file.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: None) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None) monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", None) - # File says running with active turns, but get_running_pid()==None and + # File says running with active turns, but get_running_pid_cached()==None and # get_runtime_status_running_pid finds no live PID → gateway_running False. monkeypatch.setattr(ws, "get_runtime_status_running_pid", lambda *_a, **_k: None) monkeypatch.setattr(ws, "read_runtime_status", lambda: { @@ -5244,7 +5260,7 @@ class TestGatewayBusyReadout: float so NAS can size its poll deadline without out-of-band knowledge.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: 1234) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234) monkeypatch.setattr(ws, "read_runtime_status", lambda: { "gateway_state": "running", "platforms": {}, @@ -5262,7 +5278,7 @@ class TestGatewayBusyReadout: produce a spurious busy — it degrades to 0/not-busy.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: 1234) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234) monkeypatch.setattr(ws, "read_runtime_status", lambda: { "gateway_state": "running", "platforms": {}, From 492be28e5061199413177eec66ca0c2baaba0ea3 Mon Sep 17 00:00:00 2001 From: luyifan Date: Sun, 5 Jul 2026 01:04:31 +0800 Subject: [PATCH 243/610] fix(web): bound dashboard action log tails (cherry picked from commit a4188a3e24af16e3c81e0ac00daccb2a0188d068) --- hermes_cli/web_server.py | 50 +++++++++++++++++++++++++---- tests/hermes_cli/test_web_server.py | 30 +++++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8e43b1b17d94..b023c18dd59a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3041,6 +3041,9 @@ async def run_debug_share_endpoint(body: DebugShareRequest | None = None): # --------------------------------------------------------------------------- _ACTION_LOG_DIR: Path = get_hermes_home() / "logs" +_ACTION_LOG_TAIL_MAX_BYTES = 256 * 1024 +_ACTION_LOG_TAIL_INITIAL_CHUNK_BYTES = 8 * 1024 +_ACTION_LOG_TAIL_MAX_CHUNK_BYTES = 64 * 1024 # Short ``name`` (from the URL) → absolute log file path. _ACTION_LOG_FILES: Dict[str, str] = { @@ -3142,17 +3145,50 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen: def _tail_lines(path: Path, n: int) -> List[str]: - """Return the last ``n`` lines of ``path``. Reads the whole file — fine - for our small per-action logs. Binary-decoded with ``errors='replace'`` - so log corruption doesn't 500 the endpoint.""" - if not path.exists(): + """Return the last ``n`` lines of ``path`` without loading huge logs.""" + if n <= 0 or not path.exists(): return [] try: - text = path.read_text(encoding="utf-8", errors="replace") + size = path.stat().st_size except OSError: return [] - lines = text.splitlines() - return lines[-n:] if n > 0 else lines + if size <= 0: + return [] + + min_offset = max(0, size - _ACTION_LOG_TAIL_MAX_BYTES) + offset = size + chunk_size = _ACTION_LOG_TAIL_INITIAL_CHUNK_BYTES + newline_count = 0 + chunks: List[bytes] = [] + drop_partial_first_line = False + + try: + with path.open("rb") as handle: + while offset > min_offset and newline_count <= n: + read_size = min(chunk_size, offset - min_offset) + offset -= read_size + handle.seek(offset) + chunk = handle.read(read_size) + chunks.append(chunk) + newline_count += chunk.count(b"\n") + chunk_size = min( + chunk_size * 2, + _ACTION_LOG_TAIL_MAX_CHUNK_BYTES, + ) + if offset > 0: + handle.seek(offset - 1) + drop_partial_first_line = handle.read(1) != b"\n" + except OSError: + return [] + + lines = ( + b"".join(reversed(chunks)) + .decode("utf-8", errors="replace") + .splitlines() + ) + if drop_partial_first_line and lines: + lines = lines[1:] + return lines[-n:] def _gateway_subcommand(profile: Optional[str], verb: str) -> List[str]: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 5daf308c6478..748800ca1b28 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1620,6 +1620,36 @@ class TestWebServerEndpoints: "pid": 99, } + def test_action_status_tails_large_log_without_read_text(self, tmp_path, monkeypatch): + import hermes_cli.web_server as web_server + + monkeypatch.setattr(web_server, "_ACTION_LOG_DIR", tmp_path) + web_server._ACTION_PROCS.pop("hermes-update", None) + web_server._ACTION_RESULTS.pop("hermes-update", None) + + log_path = tmp_path / web_server._ACTION_LOG_FILES["hermes-update"] + log_path.write_text( + "stale-start\n" + + ("x" * (web_server._ACTION_LOG_TAIL_MAX_BYTES + 1024)) + + "\ntail-one\ntail-two\n", + encoding="utf-8", + ) + assert log_path.stat().st_size > web_server._ACTION_LOG_TAIL_MAX_BYTES + + original_read_text = Path.read_text + + def fail_if_status_reads_whole_log(path, *args, **kwargs): + if path == log_path: + raise AssertionError("action status must not read the entire log") + return original_read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", fail_if_status_reads_whole_log) + + resp = self.client.get("/api/actions/hermes-update/status?lines=3") + + assert resp.status_code == 200 + assert resp.json()["lines"] == ["tail-one", "tail-two"] + def test_get_status_filters_unconfigured_gateway_platforms(self, monkeypatch): import gateway.config as gateway_config From d7e4d94e2fa45d921ad70727bf932affe3f10275 Mon Sep 17 00:00:00 2001 From: sebastianlutycz Date: Wed, 8 Jul 2026 17:31:34 +0530 Subject: [PATCH 244/610] perf(dashboard): recursive-CTE descendant lookup in _session_latest_descendant _session_latest_descendant fetched EVERY sessions row and built the parent->children tree in Python on each call. Replace with a recursive CTE that loads only the target session's descendant branch. Hand-applied from PR #39140 (the schema-init cache and Rust PTY bridge parts of that PR are intentionally NOT salvaged here); main's function signature gained a db parameter since the PR was cut. (cherry picked from commit 8ed5e54f65c706d197d0ea554232d5071d0279df) --- hermes_cli/web_server.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b023c18dd59a..2c324e547eec 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -9303,7 +9303,17 @@ def _session_latest_descendant(session_id: str, db): rows = [] if conn is not None: raw_rows = conn.execute( - "SELECT id, parent_session_id, started_at FROM sessions" + """ + WITH RECURSIVE descendants(id, parent_session_id, started_at) AS ( + SELECT id, parent_session_id, started_at FROM sessions WHERE id = ? + UNION ALL + SELECT s.id, s.parent_session_id, s.started_at + FROM sessions s + JOIN descendants d ON s.parent_session_id = d.id + ) + SELECT id, parent_session_id, started_at FROM descendants + """, + (sid,), ).fetchall() for row in raw_rows: rows.append({ From 24d5bda1ef2ff1cef51319c0506c9f385081e9e0 Mon Sep 17 00:00:00 2001 From: 0-CYBERDYNE-SYSTEMS-0 Date: Wed, 8 Jul 2026 17:32:19 +0530 Subject: [PATCH 245/610] fix(dashboard): run GET /api/sessions session-DB read off the event loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip the handler from async def to sync def so FastAPI executes it in its threadpool: the SessionDB open + list_sessions_rich query no longer block the single uvicorn event loop. Residual hunk from PR #53966 — that PR's get_profiles_sessions flip already landed via #54523/1bb7b59c5, and its get_status offload is superseded by #58238's read_only + timeout variant in this branch. (cherry picked from commit 414c12a40db07b941b0c589674bc6aad3f090ff7) --- hermes_cli/web_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 2c324e547eec..c5978ebfff4a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3830,7 +3830,7 @@ async def get_action_status(name: str, lines: int = 200): @app.get("/api/sessions") -async def get_sessions( +def get_sessions( limit: int = 20, offset: int = 0, min_messages: int = 0, From 206c0423920759f1c302e949051ed2ac50305af4 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:33:23 +0530 Subject: [PATCH 246/610] chore: AUTHOR_MAP entries for salvaged contributors (#53511/#53966/#39140) --- scripts/release.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 9e1b4ec54fcc..12c30d4cb148 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1926,6 +1926,9 @@ AUTHOR_MAP = { "kiljadn@gmail.com": "designnotdrum", # PR #56480 salvage (toolset static-inference fix) "lavya@loom.local": "LavyaTandel", # PR #57893 salvage local git identity (envelope-layout cache markers on tool/empty-assistant messages; #57845) "601709253@qq.com": "SquabbyZ", # PR #59682 salvage (in-container dashboard WS loopback host; #58993) + "rodisoft1@gmail.com": "0disoft", # PR #53511 salvage (gateway PID probe TTL cache) + "craigs.seller.sixx@gmail.com": "0-CYBERDYNE-SYSTEMS-0", # PR #53966 salvage (session DB reads off event loop) + "sebastianlutycz@users.noreply.github.com": "sebastianlutycz", # PR #39140 salvage (descendant CTE); bare noreply (no NNN+ prefix) needs explicit mapping } From 1e2ad17afd50fd21d55e8e5980f35f376db15a14 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:41:06 +0530 Subject: [PATCH 247/610] fix: descendant CTE must dedup (UNION) to survive parent-chain cycles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #39140 CTE used UNION ALL, which recurses forever if a corrupted parent chain loops (a -> b -> a) — reproduced: query never returns. The old Python walk was cycle-safe via a seen-set. UNION dedups the working set and terminates. Regression test added and mutation-verified (UNION ALL hangs the test, UNION passes). --- hermes_cli/web_server.py | 2 +- tests/hermes_cli/test_web_server.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index c5978ebfff4a..5645f91ddf7b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -9306,7 +9306,7 @@ def _session_latest_descendant(session_id: str, db): """ WITH RECURSIVE descendants(id, parent_session_id, started_at) AS ( SELECT id, parent_session_id, started_at FROM sessions WHERE id = ? - UNION ALL + UNION SELECT s.id, s.parent_session_id, s.started_at FROM sessions s JOIN descendants d ON s.parent_session_id = d.id diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 748800ca1b28..c67371b4e334 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1138,6 +1138,29 @@ class TestWebServerEndpoints: assert worker_resp.status_code == 200 assert worker_resp.json()["session_id"] == "worker-tip" + def test_latest_descendant_survives_parent_cycle(self): + """Regression for the #39140 CTE salvage: a corrupted parent chain + that loops (a -> b -> a) must terminate (UNION dedup) instead of + recursing forever like UNION ALL would.""" + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session(session_id="cyc-a", source="cli") + db.create_session( + session_id="cyc-b", source="cli", parent_session_id="cyc-a" + ) + db._conn.execute( + "UPDATE sessions SET parent_session_id='cyc-b' WHERE id='cyc-a'" + ) + db._conn.commit() + finally: + db.close() + + resp = self.client.get("/api/sessions/cyc-a/latest-descendant") + assert resp.status_code == 200 + assert resp.json()["session_id"] == "cyc-b" + def test_analytics_endpoints_read_requested_profile(self): from hermes_state import SessionDB from hermes_cli import profiles as profiles_mod From 664878b0baa0c99ce58a40a13e09140df6f9f261 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:43:31 +0530 Subject: [PATCH 248/610] fix: guard /api/status active count against missing state.db Review finding: SessionDB(read_only=True) requires the DB file to exist (its documented contract says callers guard on db_path.exists()); on a fresh install every /api/status poll paid an OperationalError until the first session was written. Short-circuit to 0 when state.db is absent. Tests: fresh-install guard + existing read_only test adjusted. --- hermes_cli/web_server.py | 8 +++++++- tests/hermes_cli/test_web_server.py | 23 ++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 5645f91ddf7b..874accdb0a92 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1183,7 +1183,13 @@ def _count_status_active_sessions() -> int: connection so /api/status never tries to initialise or migrate state.db while another Hermes process is writing to it. """ - from hermes_state import SessionDB + from hermes_state import DEFAULT_DB_PATH, SessionDB + + # read_only opens require the DB to already exist (see SessionDB.__init__ + # read_only contract) — on a fresh install every /api/status poll would + # otherwise pay an OperationalError until the first session is written. + if not DEFAULT_DB_PATH.exists(): + return 0 db = SessionDB(read_only=True) try: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index c67371b4e334..87860b5d9ff5 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -267,8 +267,15 @@ class TestWebServerEndpoints: assert "active_sessions" in data assert data["can_update_hermes"] is True - def test_status_active_session_count_uses_read_only_db(self, monkeypatch): + def test_status_active_session_count_uses_read_only_db(self, monkeypatch, tmp_path): import hermes_cli.web_server as web_server + import hermes_state + + # Satisfy the fresh-install guard: read_only opens require the DB + # file to already exist. + fake_db_path = tmp_path / "state.db" + fake_db_path.touch() + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", fake_db_path) captured = {} @@ -293,6 +300,20 @@ class TestWebServerEndpoints: assert web_server._count_status_active_sessions() == 1 assert captured == {"read_only": True, "limit": 50, "closed": True} + def test_status_active_session_count_fresh_install_returns_zero(self, monkeypatch, tmp_path): + """No state.db yet (fresh install): return 0 without attempting a + read-only open, which would raise OperationalError on every poll.""" + import hermes_cli.web_server as web_server + import hermes_state + + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "absent.db") + + def _boom(*a, **k): + raise AssertionError("SessionDB must not be constructed when db file is absent") + + monkeypatch.setattr("hermes_state.SessionDB", _boom) + assert web_server._count_status_active_sessions() == 0 + def test_get_status_degrades_when_active_session_count_fails(self, monkeypatch): import hermes_cli.web_server as web_server From c95cf313c9c33abc1485449cd1c2b74e86129740 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:55:45 +0530 Subject: [PATCH 249/610] test: patch the cached PID probe name in profile-unification status tests get_status now probes via get_running_pid_cached() (#53511 salvage); these tests were added on main after that PR was cut and still patched web_server.get_running_pid, so their fakes were bypassed and CI slice 5/8 failed. Patch the name the handler actually calls. --- tests/hermes_cli/test_web_server_profile_unification.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/hermes_cli/test_web_server_profile_unification.py b/tests/hermes_cli/test_web_server_profile_unification.py index 7cc3ba8351d4..260892000411 100644 --- a/tests/hermes_cli/test_web_server_profile_unification.py +++ b/tests/hermes_cli/test_web_server_profile_unification.py @@ -458,7 +458,9 @@ class TestProfileScopedGateway: return None monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1)) - monkeypatch.setattr(web_server, "get_running_pid", fake_get_running_pid) + # get_status probes via the TTL-cached wrapper (PR #53511 salvage); + # patch the cached name so the fake still intercepts the probe. + monkeypatch.setattr(web_server, "get_running_pid_cached", fake_get_running_pid) monkeypatch.setattr( web_server, "read_runtime_status", @@ -493,7 +495,7 @@ class TestProfileScopedGateway: "updated_at": "2026-06-17T00:00:00+00:00", } monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1)) - monkeypatch.setattr(web_server, "get_running_pid", lambda: None) + monkeypatch.setattr(web_server, "get_running_pid_cached", lambda: None) monkeypatch.setattr(web_server, "read_runtime_status", lambda: runtime) monkeypatch.setattr( web_server, "get_runtime_status_running_pid", lambda payload: 4242 From 0d5549a945d7ba320c14415840058710be0a9d3b Mon Sep 17 00:00:00 2001 From: mahdiwafy Date: Tue, 7 Jul 2026 23:01:58 +0700 Subject: [PATCH 250/610] fix(api): add pagination to GET /api/sessions/{id}/messages The session messages endpoint returned ALL messages in a single response with no limit/offset. Sessions with 500+ messages produced 1.2-1.6 MB JSON payloads, causing GIL starvation and WebSocket timeouts on the Desktop client (#60155). Add optional limit/offset query params to both the API endpoint and SessionDB.get_messages(). Limit clamped to 500 max per page. Response now includes a pagination object with limit/offset/returned count. Backward compatible: callers that omit limit get the old behavior (all messages). Closes #60155 (cherry picked from commit d58396b154efc2e66223528bec7dae23a5b40334) --- hermes_cli/web_server.py | 21 ++++++++++++++++++--- hermes_state.py | 24 ++++++++++++++++++------ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 874accdb0a92..5069828d8bf0 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -9549,15 +9549,30 @@ async def get_session_latest_descendant( db.close() @app.get("/api/sessions/{session_id}/messages") -async def get_session_messages(session_id: str, profile: Optional[str] = None): +async def get_session_messages( + session_id: str, + profile: Optional[str] = None, + limit: Optional[int] = None, + offset: int = 0, +): db = _open_session_db_for_profile(profile) try: sid = db.resolve_session_id(session_id) if not sid: raise HTTPException(status_code=404, detail="Session not found") sid = db.resolve_resume_session_id(sid) - messages = db.get_messages(sid) - return {"session_id": sid, "messages": messages} + # Clamp limit to prevent abuse (max 500 per page) + _limit = min(limit, 500) if limit is not None else None + messages = db.get_messages(sid, limit=_limit, offset=offset) + return { + "session_id": sid, + "messages": messages, + "pagination": { + "limit": _limit, + "offset": offset, + "returned": len(messages), + }, + } finally: db.close() diff --git a/hermes_state.py b/hermes_state.py index 9071f4b34b05..876b0ef3780c 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -3710,7 +3710,11 @@ class SessionDB: def get_messages( - self, session_id: str, include_inactive: bool = False + self, + session_id: str, + include_inactive: bool = False, + limit: Optional[int] = None, + offset: int = 0, ) -> List[Dict[str, Any]]: """Load messages for a session in insertion order. @@ -3721,14 +3725,22 @@ class SessionDB: Ordered by AUTOINCREMENT id (true insertion order) rather than timestamp — see c03acca50 for the WSL2 clock-regression rationale. + + When ``limit`` is provided, returns at most ``limit`` messages + starting from ``offset`` (0-based, in insertion order). Enables + pagination for the API endpoint to avoid loading entire transcripts. """ active_clause = "" if include_inactive else " AND active = 1" + sql = ( + "SELECT * FROM messages WHERE session_id = ?" + f"{active_clause} ORDER BY id" + ) + params: list = [session_id] + if limit is not None: + sql += " LIMIT ? OFFSET ?" + params.extend([limit, offset]) with self._lock: - cursor = self._conn.execute( - "SELECT * FROM messages WHERE session_id = ?" - f"{active_clause} ORDER BY id", - (session_id,), - ) + cursor = self._conn.execute(sql, params) rows = cursor.fetchall() result = [] for row in rows: From 4df16c429bca248095a92d707ddaf4c1ec9ed2a2 Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Sun, 5 Jul 2026 11:47:14 -0700 Subject: [PATCH 251/610] Refresh on upstream/main: resolve conflicts (no behavior change) Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 1e70eaa49a5319dc1ea4c9131610d638b7327e7a) --- hermes_cli/web_server.py | 29 +++++++++++++ tests/hermes_cli/test_web_server.py | 64 +++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 5069828d8bf0..de3657812e9f 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3835,6 +3835,23 @@ async def get_action_status(name: str, lines: int = 200): } +# Per-row fields that no session LIST consumer reads but that dominate the +# payload. ``system_prompt`` is the fully rendered prompt — tens of KB per +# row — and made a 21-row /api/sessions response 528KB (96% dead weight), +# re-fetched by the desktop sidebar on every refresh. The desktop's +# SessionInfo type doesn't declare either field and the web UI never touches +# them; ``GET /api/sessions/{id}`` detail reads stay complete. List callers +# that genuinely need the full rows can pass ``?full=1``. +_SESSION_LIST_HEAVY_FIELDS = ("system_prompt", "model_config") + + +def _strip_session_list_rows(sessions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + for s in sessions: + for key in _SESSION_LIST_HEAVY_FIELDS: + s.pop(key, None) + return sessions + + @app.get("/api/sessions") def get_sessions( limit: int = 20, @@ -3845,6 +3862,7 @@ def get_sessions( source: str = None, exclude_sources: str = None, cwd_prefix: str = None, + full: bool = False, profile: Optional[str] = None, ): """List sessions. @@ -3858,6 +3876,9 @@ def get_sessions( start time) or ``recent`` (by latest activity across the compression chain). ``recent`` keeps a long-running conversation on the first page after it auto-compresses into a fresh continuation id. + + Rows omit ``system_prompt``/``model_config`` (the payload-dominating + fields no list UI reads) unless ``full=1`` is passed. """ if archived not in ("exclude", "only", "include"): raise HTTPException( @@ -3914,6 +3935,8 @@ def get_sessions( s["is_default_profile"] = profile_name == "default" # SQLite stores the flag as 0/1; expose a real JSON boolean. s["archived"] = bool(s.get("archived")) + if not full: + _strip_session_list_rows(sessions) return {"sessions": sessions, "total": total, "limit": limit, "offset": offset} finally: db.close() @@ -3934,6 +3957,7 @@ def get_profiles_sessions( profile: str = "all", source: str = None, exclude_sources: str = None, + full: bool = False, ): """Unified, read-only session list aggregated across ALL profiles. @@ -3943,6 +3967,9 @@ def get_profiles_sessions( browsable list and only spins up a profile's backend when the user actually interacts (sends a message). A user with a single (default) profile gets the same rows as ``/api/sessions``, just tagged ``profile="default"``. + + Rows omit ``system_prompt``/``model_config`` unless ``full=1`` — same + list projection as ``/api/sessions``. """ if archived not in ("exclude", "only", "include"): raise HTTPException(status_code=400, detail="archived must be one of: exclude, only, include") @@ -4033,6 +4060,8 @@ def get_profiles_sessions( sort_key = "last_active" if order == "recent" else "started_at" merged.sort(key=lambda s: s.get(sort_key) or s.get("started_at") or 0, reverse=True) window = merged[offset:offset + limit] + if not full: + _strip_session_list_rows(window) return { "sessions": window, "total": total, diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 87860b5d9ff5..77086732e440 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -976,6 +976,70 @@ class TestWebServerEndpoints: assert captured["list"] == 3 assert captured["count"] == 3 + def _create_session_with_heavy_fields(self, session_id: str) -> None: + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session( + session_id=session_id, + source="cli", + system_prompt="# SOUL.md\n" + ("prompt body " * 500), + model_config={"temperature": 0.7, "notes": "x" * 200}, + ) + finally: + db.close() + + def test_get_sessions_strips_heavy_fields_by_default(self): + """List rows must omit system_prompt/model_config. + + system_prompt is the fully rendered prompt (tens of KB per row) and + dominated the sidebar payload (96% of a 528KB response); no list UI + reads it. Detail reads (GET /api/sessions/{id}) stay complete. + """ + self._create_session_with_heavy_fields("lean-list-row") + + resp = self.client.get("/api/sessions?limit=20&offset=0") + assert resp.status_code == 200 + rows = [s for s in resp.json()["sessions"] if s["id"] == "lean-list-row"] + assert rows, "created session missing from list" + row = rows[0] + assert "system_prompt" not in row + assert "model_config" not in row + # The light fields the sidebar actually renders must survive. + for key in ("id", "source", "started_at", "message_count", "is_active"): + assert key in row + + def test_get_sessions_full_param_keeps_heavy_fields(self): + """?full=1 is the escape hatch for callers that need complete rows.""" + self._create_session_with_heavy_fields("full-list-row") + + resp = self.client.get("/api/sessions?limit=20&offset=0&full=1") + assert resp.status_code == 200 + rows = [s for s in resp.json()["sessions"] if s["id"] == "full-list-row"] + assert rows, "created session missing from list" + row = rows[0] + assert row["system_prompt"].startswith("# SOUL.md") + assert "temperature" in (row["model_config"] or "") + + def test_profiles_sessions_strips_heavy_fields_by_default(self): + """The cross-profile aggregate applies the same list projection.""" + self._create_session_with_heavy_fields("lean-profiles-row") + + resp = self.client.get("/api/profiles/sessions?limit=20&offset=0") + assert resp.status_code == 200 + rows = [s for s in resp.json()["sessions"] if s["id"] == "lean-profiles-row"] + assert rows, "created session missing from profiles list" + row = rows[0] + assert "system_prompt" not in row + assert "model_config" not in row + assert row["profile"] == "default" + + full = self.client.get("/api/profiles/sessions?limit=20&offset=0&full=1") + assert full.status_code == 200 + full_rows = [s for s in full.json()["sessions"] if s["id"] == "lean-profiles-row"] + assert full_rows and full_rows[0]["system_prompt"].startswith("# SOUL.md") + def test_rename_session_updates_title(self): """PATCH /api/sessions/{id} renames a session (regression: the route was missing entirely, so the desktop rename dialog got a 405).""" From 22eb1af23a89abf24e388dc9ed1dd262d9ca26d1 Mon Sep 17 00:00:00 2001 From: CodeForgeNet Date: Wed, 17 Jun 2026 01:54:42 +0530 Subject: [PATCH 252/610] perf(state): add compact_rows to skip system_prompt blob in session list queries list_sessions_rich and _get_session_rich_row previously used SELECT s.*, pulling the system_prompt TEXT blob on every row even for dashboard and picker callers that never display it. On large databases this blob routinely runs to tens of kilobytes per session, causing unnecessary B-tree I/O. Add compact_rows=False param to both functions. When True, an explicit column list omitting system_prompt is substituted for s.* in both the simple and the recursive-CTE (order_by_last_active) query paths. Default is False so all existing callers are unaffected. Update dashboard and session-picker callers in web_server.py and tui_gateway/server.py to pass compact_rows=True. Add seven regression tests covering: omission of system_prompt, presence of all metadata fields, both query paths, _get_session_rich_row, and backward-compat default. (cherry picked from commit c470cbd3042d95a62708cb5572118efd964f7699) --- hermes_cli/web_server.py | 3 +- hermes_state.py | 38 ++++++++++++++++++++---- tests/test_hermes_state.py | 60 ++++++++++++++++++++++++++++++++++++++ tui_gateway/server.py | 6 ++-- 4 files changed, 98 insertions(+), 9 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index de3657812e9f..8b820901c13e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2175,7 +2175,6 @@ def _git_path(path: str) -> str: class GitPathBody(BaseModel): path: str - class GitFileBody(BaseModel): path: str file: Optional[str] = None @@ -3914,6 +3913,7 @@ def get_sessions( include_archived=include_archived, archived_only=archived_only, order_by_last_active=order == "recent", + compact_rows=True, ) total = db.session_count( source=source or None, @@ -4032,6 +4032,7 @@ def get_profiles_sessions( include_archived=include_archived, archived_only=archived_only, order_by_last_active=order == "recent", + compact_rows=True, ) profile_total = db.session_count( source=source_filter, diff --git a/hermes_state.py b/hermes_state.py index 876b0ef3780c..16998fb0669c 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -2947,6 +2947,21 @@ class SessionDB: current = child_id return current + # All sessions columns except the large system_prompt blob, each prefixed + # with the "s" table alias used in list_sessions_rich/_get_session_rich_row + # queries. Used when compact_rows=True to avoid reading the blob for + # dashboard and picker callers that only need lightweight metadata. + _SESSION_COMPACT_COLS = ( + "s.id, s.source, s.user_id, s.model, s.model_config, " + "s.parent_session_id, s.started_at, s.ended_at, s.end_reason, " + "s.message_count, s.tool_call_count, s.input_tokens, s.output_tokens, " + "s.cache_read_tokens, s.cache_write_tokens, s.reasoning_tokens, " + "s.cwd, s.billing_provider, s.billing_base_url, s.billing_mode, " + "s.estimated_cost_usd, s.actual_cost_usd, s.cost_status, s.cost_source, " + "s.pricing_version, s.title, s.api_call_count, s.handoff_state, " + "s.handoff_platform, s.handoff_error, s.rewind_count, s.archived" + ) + def distinct_session_cwds(self, include_archived: bool = False) -> List[Dict[str, Any]]: """Distinct non-empty session cwds with usage stats, for repo discovery. @@ -2988,6 +3003,7 @@ class SessionDB: archived_only: bool = False, id_query: str = None, search_query: str = None, + compact_rows: bool = False, ) -> List[Dict[str, Any]]: """List sessions with preview (first user message) and last active timestamp. @@ -3021,6 +3037,12 @@ class SessionDB: its forward compression chain). A punctuation-stripped variant is also matched so e.g. ``an94`` finds ``AN-94``. Only honored in the ``order_by_last_active`` path. + + Pass ``compact_rows=True`` for dashboard and picker callers that only + need lightweight metadata. This omits the ``system_prompt`` blob from + the SELECT so SQLite never copies it out of the B-tree page — a + significant I/O saving on large databases where the blob routinely + runs to tens of kilobytes per row. """ where_clauses = [] params = [] @@ -3138,6 +3160,7 @@ class SessionDB: outer_where = ( f"{where_sql} AND {combined}" if where_sql else f"WHERE {combined}" ) + _sel = self._SESSION_COMPACT_COLS if compact_rows else "s.*" query = f""" WITH RECURSIVE chain(root_id, cur_id) AS ( SELECT s.id, s.id FROM sessions s {where_sql} @@ -3161,7 +3184,7 @@ class SessionDB: FROM chain GROUP BY root_id ) - SELECT s.*, + SELECT {_sel}, COALESCE( (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63) FROM messages m @@ -3184,8 +3207,9 @@ class SessionDB: # only applies to the outer select. params = params + params + id_params + [limit, offset] else: + _sel = self._SESSION_COMPACT_COLS if compact_rows else "s.*" query = f""" - SELECT s.*, + SELECT {_sel}, COALESCE( (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63) FROM messages m @@ -3322,13 +3346,17 @@ class SessionDB: runs.append(s) return runs - def _get_session_rich_row(self, session_id: str) -> Optional[Dict[str, Any]]: + def _get_session_rich_row(self, session_id: str, compact_rows: bool = False) -> Optional[Dict[str, Any]]: """Fetch a single session with the same enriched columns as ``list_sessions_rich`` (preview + last_active). Returns None if the session doesn't exist. + + Pass ``compact_rows=True`` to omit the ``system_prompt`` blob (see + ``list_sessions_rich`` for details). """ - query = """ - SELECT s.*, + _sel = self._SESSION_COMPACT_COLS if compact_rows else "s.*" + query = f""" + SELECT {_sel}, COALESCE( (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63) FROM messages m diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 4ad888442afc..56fa0d85d8e5 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -5362,3 +5362,63 @@ def test_refresh_compression_lock_requires_holder_and_preserves_reclaimability(d monkeypatch.setattr(hermes_state.time, "time", lambda: 1016.0) assert db.try_acquire_compression_lock("s1", "holder-b", ttl_seconds=10.0) is True + + +# ========================================================================= +# compact_rows — lightweight column projection (issue #47414) +# ========================================================================= + +class TestCompactRows: + """list_sessions_rich and _get_session_rich_row with compact_rows=True + must omit system_prompt but return all other metadata fields.""" + + def _create(self, db, sid, *, system_prompt="big blob " * 500): + db.create_session(session_id=sid, source="cli", model="m") + db.update_system_prompt(sid, system_prompt) + return sid + + def test_compact_rows_omits_system_prompt(self, db): + self._create(db, "s1") + rows = db.list_sessions_rich(compact_rows=True) + assert len(rows) == 1 + assert "system_prompt" not in rows[0] + + def test_full_rows_include_system_prompt(self, db): + self._create(db, "s1", system_prompt="keep me") + rows = db.list_sessions_rich(compact_rows=False) + assert rows[0]["system_prompt"] == "keep me" + + def test_compact_rows_preserves_metadata_fields(self, db): + self._create(db, "s1") + rows = db.list_sessions_rich(compact_rows=True) + row = rows[0] + for field in ("id", "source", "model", "started_at", "message_count", + "input_tokens", "output_tokens", "title", "cwd", + "archived", "preview", "last_active"): + assert field in row, f"missing field: {field}" + + def test_compact_rows_order_by_last_active(self, db): + """compact_rows=True also works with the CTE / order_by_last_active path.""" + self._create(db, "s1") + self._create(db, "s2") + rows = db.list_sessions_rich(compact_rows=True, order_by_last_active=True) + assert len(rows) == 2 + assert all("system_prompt" not in r for r in rows) + + def test_get_session_rich_row_compact_omits_system_prompt(self, db): + self._create(db, "s1", system_prompt="should be gone") + row = db._get_session_rich_row("s1", compact_rows=True) + assert row is not None + assert "system_prompt" not in row + assert row["id"] == "s1" + + def test_get_session_rich_row_full_includes_system_prompt(self, db): + self._create(db, "s1", system_prompt="stay") + row = db._get_session_rich_row("s1", compact_rows=False) + assert row["system_prompt"] == "stay" + + def test_compact_rows_default_is_false(self, db): + """Default behaviour (compact_rows not passed) is unchanged — full rows.""" + self._create(db, "s1", system_prompt="present") + rows = db.list_sessions_rich() + assert "system_prompt" in rows[0] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 8357de83daed..8e825a22669d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5314,7 +5314,7 @@ def _(rid, params: dict) -> dict: fetch_limit = max(limit * 2, 200) rows = [ s - for s in db.list_sessions_rich(source=None, limit=fetch_limit, order_by_last_active=True) + for s in db.list_sessions_rich(source=None, limit=fetch_limit, order_by_last_active=True, compact_rows=True) if (s.get("source") or "").strip().lower() not in deny ][:limit] return _ok( @@ -5361,7 +5361,7 @@ def _(rid, params: dict) -> dict: # users (lots of recent ``tool`` rows) don't get a false # "no eligible session" answer. ``session.list`` uses a # similar over-fetch strategy. - rows = db.list_sessions_rich(source=None, limit=200, order_by_last_active=True) + rows = db.list_sessions_rich(source=None, limit=200, order_by_last_active=True, compact_rows=True) for row in rows: src = (row.get("source") or "").strip().lower() if src in deny: @@ -13409,7 +13409,7 @@ def _(rid, params: dict) -> dict: cutoff = time.time() - days * 86400 rows = [ s - for s in db.list_sessions_rich(limit=500) + for s in db.list_sessions_rich(limit=500, compact_rows=True) if (s.get("started_at") or 0) >= cutoff ] return _ok( From 4f220fc88b82897e1404e189e1f06557478e8f21 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:17:50 +0530 Subject: [PATCH 253/610] fix: follow-up for salvaged #60347/#43653/#47437 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - derive the compact_rows projection from SCHEMA_SQL (parse once, cache) instead of a hardcoded column list: the original #47437 list was cut against a June schema and silently dropped session_key/chat_id/chat_type/ thread_id/display_name/origin_json/expiry_finalized/git_branch/ git_repo_root/compression_failure_* — including desktop sidebar fields. Schema-derived means declaratively reconciled new columns are included automatically; only system_prompt is excluded. - guard test pinning the schema<->projection contract (mutation-verified: dropping a column from the projection fails it) - wire compact_rows=(not full) into /api/sessions and /api/profiles/sessions so the SQL projection pairs with the API-level field strip (?full=1 still returns complete rows end-to-end) - pass compact_rows at the remaining hot list callers: /api/status active count, _session_latest_descendant fallback, /api/sessions/stats by-source - thread compact_rows through the compression-tip projection (_get_session_rich_row) so projected tips can't reintroduce the blob - add pagination tests for get_messages (#60347 shipped none): paging order, offset-past-end, active-flag interaction; add tip-projection compact test - AUTHOR_MAP entries for mahdiwafy + CodeForgeNet (plain emails) --- hermes_cli/web_server.py | 14 ++++--- hermes_state.py | 43 +++++++++++-------- scripts/release.py | 2 + tests/test_hermes_state.py | 84 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 23 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8b820901c13e..db6b6a1ec8de 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1193,7 +1193,7 @@ def _count_status_active_sessions() -> int: db = SessionDB(read_only=True) try: - sessions = db.list_sessions_rich(limit=50) + sessions = db.list_sessions_rich(limit=50, compact_rows=True) now = time.time() return sum( 1 for s in sessions @@ -3913,7 +3913,10 @@ def get_sessions( include_archived=include_archived, archived_only=archived_only, order_by_last_active=order == "recent", - compact_rows=True, + # SQL-level projection: when the caller didn't ask for full + # rows, skip the system_prompt blob inside SQLite too (pairs + # with the API-level _strip_session_list_rows below). + compact_rows=not full, ) total = db.session_count( source=source or None, @@ -4032,7 +4035,8 @@ def get_profiles_sessions( include_archived=include_archived, archived_only=archived_only, order_by_last_active=order == "recent", - compact_rows=True, + # Same SQL-level blob skip as /api/sessions (see above). + compact_rows=not full, ) profile_total = db.session_count( source=source_filter, @@ -9358,7 +9362,7 @@ def _session_latest_descendant(session_id: str, db): "started_at": row_get(row, "started_at", 2), }) else: - rows = db.list_sessions_rich(limit=10000, offset=0) + rows = db.list_sessions_rich(limit=10000, offset=0, compact_rows=True) children = {} for row in rows: @@ -9512,7 +9516,7 @@ async def get_session_stats(profile: Optional[str] = None): messages = db.message_count() by_source: Dict[str, int] = {} try: - for s in db.list_sessions_rich(limit=10000, include_archived=True): + for s in db.list_sessions_rich(limit=10000, include_archived=True, compact_rows=True): src = str(s.get("source") or "cli") by_source[src] = by_source.get(src, 0) + 1 except Exception: diff --git a/hermes_state.py b/hermes_state.py index 16998fb0669c..f276f304a79b 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -2947,20 +2947,27 @@ class SessionDB: current = child_id return current - # All sessions columns except the large system_prompt blob, each prefixed - # with the "s" table alias used in list_sessions_rich/_get_session_rich_row - # queries. Used when compact_rows=True to avoid reading the blob for - # dashboard and picker callers that only need lightweight metadata. - _SESSION_COMPACT_COLS = ( - "s.id, s.source, s.user_id, s.model, s.model_config, " - "s.parent_session_id, s.started_at, s.ended_at, s.end_reason, " - "s.message_count, s.tool_call_count, s.input_tokens, s.output_tokens, " - "s.cache_read_tokens, s.cache_write_tokens, s.reasoning_tokens, " - "s.cwd, s.billing_provider, s.billing_base_url, s.billing_mode, " - "s.estimated_cost_usd, s.actual_cost_usd, s.cost_status, s.cost_source, " - "s.pricing_version, s.title, s.api_call_count, s.handoff_state, " - "s.handoff_platform, s.handoff_error, s.rewind_count, s.archived" - ) + # Columns excluded from compact_rows projections: only the payload-heavy + # blob no list consumer renders. Everything else — including gateway + # routing fields and desktop sidebar fields like git_branch — stays, and + # the projection is derived from SCHEMA_SQL so columns added later via + # declarative reconciliation are included automatically instead of + # silently dropping out of list rows. + _SESSION_COMPACT_EXCLUDED = frozenset({"system_prompt"}) + _session_compact_cols_sql: Optional[str] = None + + @classmethod + def _compact_session_cols(cls) -> str: + """SELECT list for compact_rows: every ``sessions`` column declared in + SCHEMA_SQL except the ``system_prompt`` blob, aliased with the ``s`` + prefix used by list_sessions_rich/_get_session_rich_row queries.""" + if cls._session_compact_cols_sql is None: + declared = cls._parse_schema_columns(SCHEMA_SQL)["sessions"] + cls._session_compact_cols_sql = ", ".join( + f"s.{name}" for name in declared + if name not in cls._SESSION_COMPACT_EXCLUDED + ) + return cls._session_compact_cols_sql def distinct_session_cwds(self, include_archived: bool = False) -> List[Dict[str, Any]]: """Distinct non-empty session cwds with usage stats, for repo discovery. @@ -3160,7 +3167,7 @@ class SessionDB: outer_where = ( f"{where_sql} AND {combined}" if where_sql else f"WHERE {combined}" ) - _sel = self._SESSION_COMPACT_COLS if compact_rows else "s.*" + _sel = self._compact_session_cols() if compact_rows else "s.*" query = f""" WITH RECURSIVE chain(root_id, cur_id) AS ( SELECT s.id, s.id FROM sessions s {where_sql} @@ -3207,7 +3214,7 @@ class SessionDB: # only applies to the outer select. params = params + params + id_params + [limit, offset] else: - _sel = self._SESSION_COMPACT_COLS if compact_rows else "s.*" + _sel = self._compact_session_cols() if compact_rows else "s.*" query = f""" SELECT {_sel}, COALESCE( @@ -3260,7 +3267,7 @@ class SessionDB: if tip_id == s["id"]: projected.append(s) continue - tip_row = self._get_session_rich_row(tip_id) + tip_row = self._get_session_rich_row(tip_id, compact_rows=compact_rows) if not tip_row: projected.append(s) continue @@ -3354,7 +3361,7 @@ class SessionDB: Pass ``compact_rows=True`` to omit the ``system_prompt`` blob (see ``list_sessions_rich`` for details). """ - _sel = self._SESSION_COMPACT_COLS if compact_rows else "s.*" + _sel = self._compact_session_cols() if compact_rows else "s.*" query = f""" SELECT {_sel}, COALESCE( diff --git a/scripts/release.py b/scripts/release.py index 12c30d4cb148..341490580f03 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1929,6 +1929,8 @@ AUTHOR_MAP = { "rodisoft1@gmail.com": "0disoft", # PR #53511 salvage (gateway PID probe TTL cache) "craigs.seller.sixx@gmail.com": "0-CYBERDYNE-SYSTEMS-0", # PR #53966 salvage (session DB reads off event loop) "sebastianlutycz@users.noreply.github.com": "sebastianlutycz", # PR #39140 salvage (descendant CTE); bare noreply (no NNN+ prefix) needs explicit mapping + "wafy.081107@gmail.com": "mahdiwafy", # PR #60347 salvage (session messages pagination) + "codeforgenet@icloud.com": "CodeForgeNet", # PR #47437 salvage (compact_rows blob skip) } diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 56fa0d85d8e5..621e65e6d0f4 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -5422,3 +5422,87 @@ class TestCompactRows: self._create(db, "s1", system_prompt="present") rows = db.list_sessions_rich() assert "system_prompt" in rows[0] + + def test_compact_projection_tracks_schema(self, db): + """Behavior contract: compact rows carry EVERY sessions column except + the excluded blob — including gateway/desktop fields (git_branch, + session_key) and any column added later via declarative + reconciliation. Guards against a hardcoded column list going stale.""" + self._create(db, "s1") + live_cols = { + row[1] for row in db._conn.execute("PRAGMA table_info(sessions)") + } + row = db.list_sessions_rich(compact_rows=True)[0] + # Hardcode the one sanctioned exclusion: if the excluded set ever + # widens (or the projection silently drops a column), this fails and + # forces a conscious review of what list consumers lose. + missing = live_cols - set(row) - {"system_prompt"} + assert not missing, f"compact projection lost schema columns: {missing}" + assert "system_prompt" not in row + + def test_compact_rows_tip_projection_omits_system_prompt(self, db): + """Compression-tip projection must not reintroduce the blob: the + merged tip row is fetched with the same compact_rows flag (salvage + follow-up for #47437).""" + import time as _time + t0 = _time.time() - 3600 + db.create_session("root", "cli") + db.update_system_prompt("root", "root blob " * 200) + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "root")) + db._conn.execute( + "UPDATE sessions SET ended_at=?, end_reason='compression' WHERE id=?", + (t0 + 100, "root"), + ) + db.create_session("tip", "cli", parent_session_id="root") + db.update_system_prompt("tip", "tip blob " * 200) + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0 + 200, "tip")) + db._conn.commit() + + rows = db.list_sessions_rich(compact_rows=True) + projected = [r for r in rows if r.get("_lineage_root_id") == "root"] + assert projected, "compression root should be projected to its tip" + assert all("system_prompt" not in r for r in rows) + + +# ========================================================================= +# get_messages pagination (salvage follow-up for #60347) +# ========================================================================= + +class TestGetMessagesPagination: + """get_messages(limit=, offset=) pages in insertion order; the default + (limit=None) returns the full transcript unchanged.""" + + def _seed(self, db, n=10): + db.create_session(session_id="s1", source="cli") + for i in range(n): + db.append_message("s1", "user" if i % 2 == 0 else "assistant", f"msg-{i}") + + def test_default_returns_all_messages(self, db): + self._seed(db) + messages = db.get_messages("s1") + assert [m["content"] for m in messages] == [f"msg-{i}" for i in range(10)] + + def test_limit_pages_in_insertion_order(self, db): + self._seed(db) + page1 = db.get_messages("s1", limit=4, offset=0) + page2 = db.get_messages("s1", limit=4, offset=4) + page3 = db.get_messages("s1", limit=4, offset=8) + assert [m["content"] for m in page1] == ["msg-0", "msg-1", "msg-2", "msg-3"] + assert [m["content"] for m in page2] == ["msg-4", "msg-5", "msg-6", "msg-7"] + assert [m["content"] for m in page3] == ["msg-8", "msg-9"] + + def test_offset_past_end_returns_empty(self, db): + self._seed(db, n=3) + assert db.get_messages("s1", limit=5, offset=10) == [] + + def test_pagination_respects_active_flag(self, db): + """Soft-deleted (inactive) rows must not consume page slots.""" + self._seed(db, n=6) + # Soft-delete the first two rows the way rewind does. + db._conn.execute( + "UPDATE messages SET active = 0 WHERE session_id = 's1' " + "AND id IN (SELECT id FROM messages WHERE session_id = 's1' ORDER BY id LIMIT 2)" + ) + db._conn.commit() + page = db.get_messages("s1", limit=3, offset=0) + assert [m["content"] for m in page] == ["msg-2", "msg-3", "msg-4"] From 7570bb4ad4904afade713a9cd6950b2474581dca Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:30:17 +0530 Subject: [PATCH 254/610] fix: offset-without-limit was silently ignored in get_messages Review finding: get_messages(offset=N) with no limit dropped the OFFSET entirely. SQLite requires a LIMIT clause for OFFSET, so emit LIMIT -1 (unbounded) when only offset is given. Regression test added. --- hermes_state.py | 7 +++++-- tests/test_hermes_state.py | 7 +++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index f276f304a79b..a0b6dfd09ccd 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -3764,6 +3764,8 @@ class SessionDB: When ``limit`` is provided, returns at most ``limit`` messages starting from ``offset`` (0-based, in insertion order). Enables pagination for the API endpoint to avoid loading entire transcripts. + ``offset`` alone (without ``limit``) also pages — SQLite requires a + LIMIT clause for OFFSET, so it's emitted as ``LIMIT -1`` (unbounded). """ active_clause = "" if include_inactive else " AND active = 1" sql = ( @@ -3771,9 +3773,10 @@ class SessionDB: f"{active_clause} ORDER BY id" ) params: list = [session_id] - if limit is not None: + if limit is not None or offset: + # SQLite's OFFSET requires LIMIT; -1 means "no limit". sql += " LIMIT ? OFFSET ?" - params.extend([limit, offset]) + params.extend([-1 if limit is None else limit, offset]) with self._lock: cursor = self._conn.execute(sql, params) rows = cursor.fetchall() diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 621e65e6d0f4..99f2ccb71e9c 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -5506,3 +5506,10 @@ class TestGetMessagesPagination: db._conn.commit() page = db.get_messages("s1", limit=3, offset=0) assert [m["content"] for m in page] == ["msg-2", "msg-3", "msg-4"] + + def test_offset_without_limit_pages(self, db): + """offset alone must not be silently ignored (review finding): + SQLite needs LIMIT for OFFSET, emitted as LIMIT -1.""" + self._seed(db, n=5) + rows = db.get_messages("s1", offset=3) + assert [m["content"] for m in rows] == ["msg-3", "msg-4"] From 7a25017a00225359407a33b32c3252087c513925 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:57:39 +0530 Subject: [PATCH 255/610] test: accept compact_rows kwarg in tui_gateway session fakes tui_gateway session.list/most_recent now pass compact_rows=True (#47437 salvage); the keyword-only fake signatures in test_tui_gateway_server.py rejected the new kwarg and CI slice 6/8 failed with TypeError. Other list_sessions_rich fakes use **kwargs and are unaffected. --- tests/test_tui_gateway_server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index cc6ff4f5b723..711956901eed 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -6590,7 +6590,7 @@ def test_session_most_recent_returns_first_non_denied(monkeypatch): """Drops `tool` rows like session.list does, returns the first hit.""" class _DB: - def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False): + def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False, compact_rows=False): return [ {"id": "tool-1", "source": "tool", "title": "noise", "started_at": 100}, {"id": "tui-1", "source": "tui", "title": "real", "started_at": 99}, @@ -6609,7 +6609,7 @@ def test_session_most_recent_returns_first_non_denied(monkeypatch): def test_session_most_recent_returns_null_when_only_tool_rows(monkeypatch): class _DB: - def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False): + def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False, compact_rows=False): return [{"id": "tool-1", "source": "tool", "started_at": 1}] monkeypatch.setattr(server, "_get_db", lambda: _DB()) @@ -6627,7 +6627,7 @@ def test_session_most_recent_folds_db_exception_into_null_result(monkeypatch): 'no answer' (Copilot review on #17130).""" class _BrokenDB: - def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False): + def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False, compact_rows=False): raise RuntimeError("db locked") monkeypatch.setattr(server, "_get_db", lambda: _BrokenDB()) From 31e39dec84cbce925fb99cef9dccb739e2f92474 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:30:19 +0530 Subject: [PATCH 256/610] test: expect compact_rows in the read-only status-count fake Rebase reconciliation with #60884: _count_status_active_sessions (from #58238) now passes compact_rows=True (this branch's #47437 projection), so the fake asserts both. --- tests/hermes_cli/test_web_server.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 77086732e440..519ccf1518e0 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -283,8 +283,9 @@ class TestWebServerEndpoints: def __init__(self, *args, **kwargs): captured["read_only"] = kwargs.get("read_only") - def list_sessions_rich(self, limit): + def list_sessions_rich(self, limit, compact_rows=False): captured["limit"] = limit + captured["compact_rows"] = compact_rows return [ {"ended_at": None, "last_active": 95}, {"ended_at": 99, "last_active": 99}, @@ -298,7 +299,9 @@ class TestWebServerEndpoints: monkeypatch.setattr(web_server.time, "time", lambda: 100) assert web_server._count_status_active_sessions() == 1 - assert captured == {"read_only": True, "limit": 50, "closed": True} + assert captured == { + "read_only": True, "limit": 50, "compact_rows": True, "closed": True + } def test_status_active_session_count_fresh_install_returns_zero(self, monkeypatch, tmp_path): """No state.db yet (fresh install): return 0 without attempting a From 0569a637d0376e7abcf060c50f25ad9e7c31efaf Mon Sep 17 00:00:00 2001 From: gauravsaxena1997 Date: Thu, 9 Jul 2026 01:50:08 +0530 Subject: [PATCH 257/610] fix(agent): guard response.text access in _summarize_api_error against httpx.ResponseNotRead When an API error carries an httpx.Response whose body was consumed via iter_bytes() during streaming error handling (e.g. GeminiAPIError from agent/gemini_native_adapter.py), accessing .text raises httpx.ResponseNotRead. The secondary exception replaced the real, already-computed provider error (429 free-tier quota guidance) with the generic 'Attempted to access streaming response content' message on every turn. Guard the .text access so it degrades to an empty snippet and falls through to the str(error) fallback, which carries the full original message. Mirrors the existing guards in agent/error_classifier.py::_extract_error_body() and agent/gemini_native_adapter.py::gemini_http_error(). Fixes #59769 Salvaged from PR #59868 (guard + regression test); the unrelated desktop Ctrl-C fix bundled in that PR was intentionally dropped and is triaged separately. --- run_agent.py | 5 +++- tests/run_agent/test_summarize_api_error.py | 26 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/run_agent.py b/run_agent.py index a20a70248634..6fb7e0903e5c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2143,7 +2143,10 @@ class AIAgent: # widens exposure vs the old empty-body "HTTP 400" string). response = getattr(error, "response", None) if response is not None: - snippet = (getattr(response, "text", None) or "").strip() + try: + snippet = (getattr(response, "text", None) or "").strip() + except Exception: + snippet = "" if snippet: status_code = getattr(error, "status_code", None) prefix = f"HTTP {status_code}: " if status_code else "" diff --git a/tests/run_agent/test_summarize_api_error.py b/tests/run_agent/test_summarize_api_error.py index 6739d8e48e8a..ed37f7f9226f 100644 --- a/tests/run_agent/test_summarize_api_error.py +++ b/tests/run_agent/test_summarize_api_error.py @@ -12,6 +12,10 @@ provider error. This is a diagnostic improvement and is platform-agnostic. from types import SimpleNamespace +from typing import Any + +import httpx + from run_agent import AIAgent @@ -54,3 +58,25 @@ def test_empty_body_fallback_redacts_secrets(monkeypatch): summary = AIAgent._summarize_api_error(err) assert "sk-proj-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdef" not in summary + +def test_unread_streaming_response_does_not_crash_and_falls_back_to_exception_message(): + """Unread streaming responses must not replace the real provider error.""" + + class _StreamingError(Exception): + def __init__(self): + super().__init__("Gemini HTTP 429: quota exceeded") + self.status_code = 429 + self.response: Any = None + + err = _StreamingError() + + class _UnreadStreamingResponse: + @property + def text(self): + raise httpx.ResponseNotRead() + + err.response = _UnreadStreamingResponse() + summary = AIAgent._summarize_api_error(err) + assert "HTTP 429" in summary + assert "Gemini HTTP 429: quota exceeded" in summary + From 1792a3aa7261d05e8eb926a31713c9f87bacb8c2 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:50:39 +0530 Subject: [PATCH 258/610] chore: add gauravsaxena1997 to AUTHOR_MAP (PR #59868 salvage) --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 341490580f03..33ffb025c59b 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -59,6 +59,7 @@ AUTHOR_MAP = { "m888.braun@hotmail.com": "ManniBr", # PR #57417 partial salvage (gateway: fail-closed adapter resolution for unregistered secondary profiles) "austin@openvm067.space": "austinlaw076", # PR #57563 partial salvage (auth: lazy per-profile Anthropic OAuth file; gateway: whatsapp_cloud/line added to port-binding platform set) "sunsky.lau@gmail.com": "liuhao1024", # PR #56993 salvage (gateway: process-level HERMES_HOME for pid/lock/status identity files; #56986) + "gauravsaxena.jaipur@gmail.com": "gauravsaxena1997", # PR #59868 partial salvage (agent: guard response.text against httpx.ResponseNotRead in _summarize_api_error; #59769) "blueirobin02@gmail.com": "irresi", # PR #59048 salvage (gateway: scope reset banners' session info to the serving profile; #59003) "jashlee+microsoft@microsoft.com": "s905060", # PR #57943 salvage (photon: auto-reinstall stale sidecar node_modules when lockfile is newer than npm's install marker; #59169) "lohinth25@proton.me": "l0h1nth", # PR #32210 salvage (mattermost: accept leading-space slash commands from mobile clients; #25184) From 74e28f7d10b587630ec41d97a3b8a963fc4798cc Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:56:15 +0530 Subject: [PATCH 259/610] style(tests): merge import blocks in test_summarize_api_error --- tests/run_agent/test_summarize_api_error.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/run_agent/test_summarize_api_error.py b/tests/run_agent/test_summarize_api_error.py index ed37f7f9226f..f16cc76b1076 100644 --- a/tests/run_agent/test_summarize_api_error.py +++ b/tests/run_agent/test_summarize_api_error.py @@ -11,7 +11,6 @@ provider error. This is a diagnostic improvement and is platform-agnostic. """ from types import SimpleNamespace - from typing import Any import httpx From be1346cf2a0edb6e0f64bee3f5ce10d470e4fb33 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Wed, 8 Jul 2026 22:33:27 +0700 Subject: [PATCH 260/610] fix(gateway): reload fallback_providers on live agent create/reuse Gateway froze the fallback chain at process start while cron reloads it per job, so a chain configured after hermes gateway was running never reached messaging sessions. Refresh from disk on agent create and when reusing a cached agent. Fixes #60955. (cherry picked from commit b64e7155b29cc97b9f427fe2b91cba4f22c64900) --- gateway/run.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 4370a084eb17..fce858c50ede 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5008,6 +5008,42 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew pass return None + def _refresh_fallback_model(self) -> list | None: + """Re-read fallback_providers from disk for the next agent create/reuse. + + Cron already does this per job via ``get_fallback_chain``; the gateway + previously froze ``self._fallback_model`` at process start, so a chain + configured (or changed) after ``hermes gateway`` was running never + reached messaging sessions even though the same process's cron jobs + fell back correctly. Fixes #60955. + """ + self._fallback_model = self._load_fallback_model() + return self._fallback_model + + @staticmethod + def _apply_fallback_chain_to_agent(agent: Any, chain: list | None) -> None: + """Keep a cached agent's fallback chain aligned with current config. + + Skips rewrite while a cooldown is holding the agent on an already- + activated fallback provider — ``restore_primary_runtime`` owns that + turn-scoped lifecycle. When primary is active (or cooldown expired), + replace the chain so mid-uptime ``fallback_providers`` edits take + effect without requiring a gateway restart (#60955). + """ + if agent is None: + return + new_chain = list(chain or []) + rate_limited_until = getattr(agent, "_rate_limited_until", 0) or 0 + if ( + getattr(agent, "_fallback_activated", False) + and rate_limited_until > time.monotonic() + ): + return + agent._fallback_chain = new_chain + agent._fallback_model = new_chain[0] if new_chain else None + if not getattr(agent, "_fallback_activated", False): + agent._fallback_index = 0 + def _snapshot_running_agents(self) -> Dict[str, Any]: return { session_key: agent @@ -13244,7 +13280,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew chat_type=source.chat_type, thread_id=source.thread_id, session_db=getattr(self._session_db, "_db", self._session_db), - fallback_model=self._fallback_model, + # Reload from disk — do not reuse the startup snapshot (#60955). + fallback_model=self._refresh_fallback_model(), ) try: return agent.run_conversation( @@ -18062,6 +18099,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # Refresh agent max_iterations from current config # (cached agent may have been created with old config) agent.max_iterations = max_iterations + # Refresh fallback chain the same way — a chain + # configured after this agent was cached (or after + # gateway start) must reach the next turn (#60955). + self._apply_fallback_chain_to_agent( + agent, self._refresh_fallback_model(), + ) logger.debug("Reusing cached agent for session %s", session_key) reused_cached_agent = True @@ -18117,7 +18160,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew thread_id=source.thread_id, gateway_session_key=session_key, session_db=getattr(self._session_db, "_db", self._session_db), - fallback_model=self._fallback_model, + # Reload from disk — do not reuse the startup snapshot (#60955). + fallback_model=self._refresh_fallback_model(), ) if _cache_lock and _cache is not None: with _cache_lock: From e721ad89e30dbed8c3affaa2b01ff790e3da3d27 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Wed, 8 Jul 2026 22:33:27 +0700 Subject: [PATCH 261/610] test(gateway): cover fallback_providers reload for live sessions Pin reload + cached-agent apply helpers for #60955 so a mid-uptime fallback chain change reaches messaging sessions without a restart. (cherry picked from commit fafb34103509f890cce158bfdbfd4faee8982253) --- tests/gateway/test_fallback_chain_reload.py | 165 ++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 tests/gateway/test_fallback_chain_reload.py diff --git a/tests/gateway/test_fallback_chain_reload.py b/tests/gateway/test_fallback_chain_reload.py new file mode 100644 index 000000000000..a9d8cf1255ad --- /dev/null +++ b/tests/gateway/test_fallback_chain_reload.py @@ -0,0 +1,165 @@ +"""Regression tests for #60955: gateway must not freeze fallback_providers. + +Cron reloads ``fallback_providers`` from disk on every job. The gateway used to +freeze ``self._fallback_model`` at process start, so a chain configured (or +edited) after ``hermes gateway`` was already running never reached messaging +sessions — even though cron in the same process fell back correctly. + +These tests pin the reload + cached-agent apply helpers without driving the +full Feishu session path. +""" + +from __future__ import annotations + +import time +from types import SimpleNamespace + + +def test_refresh_fallback_model_rereads_config(tmp_path, monkeypatch): + from gateway.run import GatewayRunner + + monkeypatch.setattr("gateway.run._hermes_home", tmp_path) + cfg = tmp_path / "config.yaml" + cfg.write_text( + "fallback_providers:\n" + " - provider: deepseek\n" + " model: deepseek-v4-flash\n" + ) + + runner = SimpleNamespace( + _fallback_model=None, + ) + runner._load_fallback_model = GatewayRunner._load_fallback_model + bound = GatewayRunner._refresh_fallback_model.__get__(runner) + chain = bound() + + assert chain == [{"provider": "deepseek", "model": "deepseek-v4-flash"}] + assert runner._fallback_model == chain + + cfg.write_text( + "fallback_providers:\n" + " - provider: openrouter\n" + " model: anthropic/claude-sonnet-4.6\n" + ) + updated = bound() + assert updated == [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"} + ] + assert runner._fallback_model == updated + + +def test_refresh_fallback_model_clears_when_config_removed(tmp_path, monkeypatch): + from gateway.run import GatewayRunner + + monkeypatch.setattr("gateway.run._hermes_home", tmp_path) + cfg = tmp_path / "config.yaml" + cfg.write_text( + "fallback_providers:\n" + " - provider: deepseek\n" + " model: deepseek-v4-flash\n" + ) + + runner = SimpleNamespace( + _fallback_model=[{"provider": "stale", "model": "x"}], + ) + runner._load_fallback_model = GatewayRunner._load_fallback_model + bound = GatewayRunner._refresh_fallback_model.__get__(runner) + assert bound() is not None + + cfg.write_text("model:\n provider: nvidia\n") + assert bound() is None + assert runner._fallback_model is None + + +def test_apply_fallback_chain_updates_primary_agent(): + from gateway.run import GatewayRunner + + agent = SimpleNamespace( + _fallback_chain=[], + _fallback_model=None, + _fallback_index=0, + _fallback_activated=False, + _rate_limited_until=0, + ) + chain = [{"provider": "deepseek", "model": "deepseek-v4-flash"}] + GatewayRunner._apply_fallback_chain_to_agent(agent, chain) + + assert agent._fallback_chain == chain + assert agent._fallback_model == chain[0] + assert agent._fallback_index == 0 + + +def test_apply_fallback_chain_skips_while_cooldown_holds_fallback(): + """Do not clobber a live fallback activation during its cooldown window.""" + from gateway.run import GatewayRunner + + live = [{"provider": "deepseek", "model": "deepseek-v4-flash"}] + agent = SimpleNamespace( + _fallback_chain=live, + _fallback_model=live[0], + _fallback_index=1, + _fallback_activated=True, + _rate_limited_until=time.monotonic() + 30, + ) + GatewayRunner._apply_fallback_chain_to_agent( + agent, + [{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}], + ) + + assert agent._fallback_chain == live + assert agent._fallback_index == 1 + assert agent._fallback_activated is True + + +def test_apply_fallback_chain_updates_after_cooldown_expires(): + from gateway.run import GatewayRunner + + agent = SimpleNamespace( + _fallback_chain=[{"provider": "deepseek", "model": "old"}], + _fallback_model={"provider": "deepseek", "model": "old"}, + _fallback_index=1, + _fallback_activated=True, + _rate_limited_until=time.monotonic() - 1, + ) + new_chain = [{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}] + GatewayRunner._apply_fallback_chain_to_agent(agent, new_chain) + + assert agent._fallback_chain == new_chain + assert agent._fallback_model == new_chain[0] + # Activated agents keep their index; restore_primary_runtime owns reset. + assert agent._fallback_index == 1 + + +def test_background_and_main_agent_paths_call_refresh(): + """Both AIAgent construction sites must pass a refreshed chain, not the + startup snapshot. Source-level invariant — mirrors + tests/agent/test_gemini_fast_fallback.py's call-site pin. + """ + from pathlib import Path + + source = Path("gateway/run.py").read_text(encoding="utf-8") + assert "fallback_model=self._refresh_fallback_model()" in source + assert source.count("fallback_model=self._refresh_fallback_model()") >= 2 + # The stale startup-snapshot form must not remain at create sites. + assert "fallback_model=self._fallback_model," not in source + + +def test_load_fallback_model_static_unchanged_contract(tmp_path, monkeypatch): + """_load_fallback_model remains a pure static reader used by refresh.""" + from gateway.run import GatewayRunner + + monkeypatch.setattr("gateway.run._hermes_home", tmp_path) + (tmp_path / "config.yaml").write_text( + "fallback_providers:\n" + " - provider: deepseek\n" + " model: deepseek-v4-flash\n" + "fallback_model:\n" + " provider: nous\n" + " model: Hermes-4\n" + ) + + chain = GatewayRunner._load_fallback_model() + assert chain == [ + {"provider": "deepseek", "model": "deepseek-v4-flash"}, + {"provider": "nous", "model": "Hermes-4"}, + ] From 0a01b2087d0c0bb11bb7ed650b8de3e6b5205856 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:10:41 +0530 Subject: [PATCH 262/610] fix(gateway): harden fallback-chain refresh from review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on the #60987 salvage (review pass): - _refresh_fallback_model: keep last known-good chain on transient config.yaml read/parse failure (user mid-edit, torn write) — only a successful read that lacks the key clears the chain. Previously a refresh error wiped a cached agent's working fallback for the turn. - Move the cached-agent refresh+apply OUTSIDE the agent-cache lock: config.yaml read is disk I/O and the idle-sweep watcher contends on that lock (same reasoning as #52197). Per-session turn serialization keeps the post-lock apply safe. - _apply_fallback_chain_to_agent: clear _unavailable_fallback_keys when chain content actually changes, so an entry re-configured mid-uptime (e.g. credentials added) is retried instead of staying suppressed for the cached agent's lifetime; no-op refreshes keep the memo. - Tests: cwd-independent source pin (Path(__file__) anchor), pin the reuse-path apply call, + regression tests for last-known-good, memo clear-on-change, memo keep-on-unchanged (mutation-verified). --- gateway/run.py | 52 ++++++++++++-- tests/gateway/test_fallback_chain_reload.py | 80 ++++++++++++++++++++- 2 files changed, 122 insertions(+), 10 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index fce858c50ede..54f38d1bb795 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5016,8 +5016,28 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew configured (or changed) after ``hermes gateway`` was running never reached messaging sessions even though the same process's cron jobs fell back correctly. Fixes #60955. + + A TRANSIENT read/parse failure (user mid-edit of config.yaml with a + non-atomic write) keeps the last known-good chain instead of wiping a + cached agent's working fallback for that turn. Only a successful read + that genuinely lacks the key clears the chain. """ - self._fallback_model = self._load_fallback_model() + try: + import yaml as _y + cfg_path = _hermes_home / "config.yaml" + if not cfg_path.exists(): + self._fallback_model = None + return self._fallback_model + with open(cfg_path, encoding="utf-8") as _f: + cfg = _y.safe_load(_f) or {} + except Exception: + # Transient failure — keep last known-good chain. + logger.debug( + "fallback_providers refresh: config.yaml read failed; " + "keeping last known-good chain", exc_info=True, + ) + return self._fallback_model + self._fallback_model = get_fallback_chain(cfg) or None return self._fallback_model @staticmethod @@ -5039,10 +5059,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew and rate_limited_until > time.monotonic() ): return + old_chain = list(getattr(agent, "_fallback_chain", []) or []) agent._fallback_chain = new_chain agent._fallback_model = new_chain[0] if new_chain else None if not getattr(agent, "_fallback_activated", False): agent._fallback_index = 0 + # A config edit signals the user changed something — drop the + # session-scoped unavailability memo so re-configured entries + # (e.g. credentials added mid-uptime for a previously-failing + # provider) get retried instead of staying suppressed for the + # cached agent's lifetime. Only on actual content change, so + # the per-message no-op refresh keeps the memo's rate-limiting + # benefit (#60955). + if new_chain != old_chain: + unavailable = getattr(agent, "_unavailable_fallback_keys", None) + if unavailable: + unavailable.clear() def _snapshot_running_agents(self) -> Dict[str, Any]: return { @@ -18099,15 +18131,21 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # Refresh agent max_iterations from current config # (cached agent may have been created with old config) agent.max_iterations = max_iterations - # Refresh fallback chain the same way — a chain - # configured after this agent was cached (or after - # gateway start) must reach the next turn (#60955). - self._apply_fallback_chain_to_agent( - agent, self._refresh_fallback_model(), - ) logger.debug("Reusing cached agent for session %s", session_key) reused_cached_agent = True + # Lock released — refresh the fallback chain from disk for the + # reused agent OUTSIDE the cache lock (config.yaml read is disk + # I/O; the idle-sweep watcher contends on this lock and stalls + # Discord heartbeats — same reasoning as #52197). A chain + # configured after this agent was cached (or after gateway start) + # must reach the next turn (#60955). Per-session turn + # serialization (_running_agents) keeps this safe post-lock. + if reused_cached_agent and agent is not None: + self._apply_fallback_chain_to_agent( + agent, self._refresh_fallback_model(), + ) + # Lock released — now schedule cleanup of any cross-process-evicted # agent on a daemon thread so memory-provider shutdown / socket # teardown never blocks the gateway event loop or the cache lock diff --git a/tests/gateway/test_fallback_chain_reload.py b/tests/gateway/test_fallback_chain_reload.py index a9d8cf1255ad..5547db7e2630 100644 --- a/tests/gateway/test_fallback_chain_reload.py +++ b/tests/gateway/test_fallback_chain_reload.py @@ -71,6 +71,34 @@ def test_refresh_fallback_model_clears_when_config_removed(tmp_path, monkeypatch assert runner._fallback_model is None +def test_refresh_fallback_model_keeps_last_known_good_on_read_failure( + tmp_path, monkeypatch, +): + """A transient config.yaml read/parse failure (user mid-edit, non-atomic + write) must NOT wipe the last known-good chain — only a successful read + that genuinely lacks the key clears it.""" + from gateway.run import GatewayRunner + + monkeypatch.setattr("gateway.run._hermes_home", tmp_path) + cfg = tmp_path / "config.yaml" + cfg.write_text( + "fallback_providers:\n" + " - provider: deepseek\n" + " model: deepseek-v4-flash\n" + ) + + runner = SimpleNamespace(_fallback_model=None) + runner._load_fallback_model = GatewayRunner._load_fallback_model + bound = GatewayRunner._refresh_fallback_model.__get__(runner) + good = bound() + assert good == [{"provider": "deepseek", "model": "deepseek-v4-flash"}] + + # Simulate a mid-edit torn write: invalid YAML. + cfg.write_text("fallback_providers:\n - provider: [unclosed\n") + assert bound() == good + assert runner._fallback_model == good + + def test_apply_fallback_chain_updates_primary_agent(): from gateway.run import GatewayRunner @@ -130,16 +158,62 @@ def test_apply_fallback_chain_updates_after_cooldown_expires(): assert agent._fallback_index == 1 +def test_apply_fallback_chain_clears_unavailable_memo_on_content_change(): + """A config edit must drop the session-scoped unavailability memo so a + re-configured entry (credentials added mid-uptime) is retried instead of + staying suppressed for the cached agent's lifetime.""" + from gateway.run import GatewayRunner + + agent = SimpleNamespace( + _fallback_chain=[{"provider": "deepseek", "model": "old"}], + _fallback_model={"provider": "deepseek", "model": "old"}, + _fallback_index=0, + _fallback_activated=False, + _rate_limited_until=0, + _unavailable_fallback_keys={("deepseek", "old", "")}, + ) + new_chain = [{"provider": "deepseek", "model": "deepseek-v4-flash"}] + GatewayRunner._apply_fallback_chain_to_agent(agent, new_chain) + + assert agent._fallback_chain == new_chain + assert agent._unavailable_fallback_keys == set() + + +def test_apply_fallback_chain_keeps_unavailable_memo_when_unchanged(): + """The per-message no-op refresh must NOT clear the memo — it exists to + rate-limit repeated activation attempts against dead entries.""" + from gateway.run import GatewayRunner + + chain = [{"provider": "deepseek", "model": "deepseek-v4-flash"}] + memo = {("deepseek", "deepseek-v4-flash", "")} + agent = SimpleNamespace( + _fallback_chain=list(chain), + _fallback_model=chain[0], + _fallback_index=0, + _fallback_activated=False, + _rate_limited_until=0, + _unavailable_fallback_keys=set(memo), + ) + GatewayRunner._apply_fallback_chain_to_agent(agent, list(chain)) + + assert agent._unavailable_fallback_keys == memo + + def test_background_and_main_agent_paths_call_refresh(): """Both AIAgent construction sites must pass a refreshed chain, not the - startup snapshot. Source-level invariant — mirrors - tests/agent/test_gemini_fast_fallback.py's call-site pin. + startup snapshot, and the cached-agent reuse path must apply the refreshed + chain. Source-level invariant for call sites that resist unit testing. """ from pathlib import Path - source = Path("gateway/run.py").read_text(encoding="utf-8") + source = ( + Path(__file__).resolve().parent.parent.parent / "gateway" / "run.py" + ).read_text(encoding="utf-8") assert "fallback_model=self._refresh_fallback_model()" in source assert source.count("fallback_model=self._refresh_fallback_model()") >= 2 + # The cached-agent reuse path (the load-bearing fix for a long-lived + # session in a running gateway) must apply the refreshed chain. + assert "self._apply_fallback_chain_to_agent(" in source # The stale startup-snapshot form must not remain at create sites. assert "fallback_model=self._fallback_model," not in source From e21ba912210ee6b974c46bd2aae42c082da4fdfb Mon Sep 17 00:00:00 2001 From: Dixing Xu Date: Tue, 7 Jul 2026 16:41:32 +0100 Subject: [PATCH 263/610] perf(skills): speed up snapshot prompt builds Fixes #3356 Build the skills snapshot manifest in one directory walk, avoid importing gateway session context during CLI prompt startup, and reuse direct platform-list matching for snapshot entries. (cherry picked from commit 1a64c2ed04f739ce03de94ca33d20b34c8d0e3cb) --- agent/prompt_builder.py | 52 +++++++++++++++++++++++------- agent/skill_utils.py | 57 ++++++++++++++++++--------------- tests/agent/test_skill_utils.py | 11 +++++++ 3 files changed, 82 insertions(+), 38 deletions(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index abb70b543cbb..b5b2b58c3621 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -7,6 +7,7 @@ assemble pieces, then combines them with memory and ephemeral prompts. import json import logging import os +import sys import threading import contextvars from collections import OrderedDict @@ -17,6 +18,8 @@ from typing import Optional from agent.runtime_cwd import resolve_agent_cwd from agent.skill_utils import ( + EXCLUDED_SKILL_DIRS, + SKILL_SUPPORT_DIRS, extract_skill_conditions, extract_skill_description, get_all_skills_dirs, @@ -25,6 +28,7 @@ from agent.skill_utils import ( parse_frontmatter, skill_matches_environment, skill_matches_platform, + skill_matches_platform_list, ) from utils import atomic_json_write @@ -1271,13 +1275,26 @@ def clear_skills_system_prompt_cache(*, clear_snapshot: bool = False) -> None: def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]: """Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files.""" manifest: dict[str, list[int]] = {} - for filename in ("SKILL.md", "DESCRIPTION.md"): - for path in iter_skill_index_files(skills_dir, filename): + skills_dir_str = str(skills_dir) + base = os.path.join(skills_dir_str, "") + prefix_len = len(base) + for root, dirs, files in os.walk(skills_dir_str, followlinks=True): + has_skill_md = "SKILL.md" in files + dirs[:] = [ + d + for d in dirs + if d not in EXCLUDED_SKILL_DIRS + and not (has_skill_md and d in SKILL_SUPPORT_DIRS) + ] + for filename in ("SKILL.md", "DESCRIPTION.md"): + if filename not in files: + continue + path = os.path.join(root, filename) try: - st = path.stat() + st = os.stat(path) except OSError: continue - manifest[str(path.relative_to(skills_dir))] = [st.st_mtime_ns, st.st_size] + manifest[path[prefix_len:]] = [st.st_mtime_ns, st.st_size] return manifest @@ -1409,6 +1426,22 @@ def _skill_should_show( return True +def _current_session_platform_hint() -> str: + """Return the active platform without importing the gateway package on CLI startup.""" + platform = os.environ.get("HERMES_PLATFORM") or os.environ.get("HERMES_SESSION_PLATFORM") + if platform: + return platform + + session_context = sys.modules.get("gateway.session_context") + get_session_env = getattr(session_context, "get_session_env", None) if session_context else None + if get_session_env is None: + return "" + try: + return get_session_env("HERMES_SESSION_PLATFORM") or "" + except Exception: + return "" + + def build_skills_system_prompt( available_tools: "set[str] | None" = None, available_toolsets: "set[str] | None" = None, @@ -1443,15 +1476,10 @@ def build_skills_system_prompt( # ── Layer 1: in-process LRU cache ───────────────────────────────── # Include the resolved platform so per-platform disabled-skill lists # produce distinct cache entries (gateway serves multiple platforms). - from gateway.session_context import get_session_env - _platform_hint = ( - os.environ.get("HERMES_PLATFORM") - or get_session_env("HERMES_SESSION_PLATFORM") - or "" - ) + _platform_hint = _current_session_platform_hint() disabled = get_disabled_skill_names(_platform_hint or None) cache_key = ( - str(skills_dir.resolve()), + str(skills_dir), tuple(str(d) for d in external_dirs), tuple(sorted(str(t) for t in (available_tools or set()))), tuple(sorted(str(ts) for ts in (available_toolsets or set()))), @@ -1480,7 +1508,7 @@ def build_skills_system_prompt( category = entry.get("category") or "general" frontmatter_name = entry.get("frontmatter_name") or skill_name platforms = entry.get("platforms") or [] - if not skill_matches_platform({"platforms": platforms}): + if not skill_matches_platform_list(platforms): continue if frontmatter_name in disabled or skill_name in disabled: continue diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 23c8d99c997d..d07bb5324e0a 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -160,27 +160,8 @@ def parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]: # ── Platform matching ───────────────────────────────────────────────────── -def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool: - """Return True when the skill is compatible with the current OS. - - Skills declare platform requirements via a top-level ``platforms`` list - in their YAML frontmatter:: - - platforms: [macos] # macOS only - platforms: [macos, linux] # macOS and Linux - - If the field is absent or empty the skill is compatible with **all** - platforms (backward-compatible default). - - Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on - older Pythons but became ``"android"`` on Python 3.13+. Termux is a - Linux userland riding on the Android kernel, so skills tagged - ``linux`` are treated as compatible in Termux regardless of which - ``sys.platform`` value Python reports. Individual Linux commands - inside a skill may still misbehave (no systemd, BusyBox utils, no - apt/dnf, etc.) but that is on the skill, not on platform gating. - """ - platforms = frontmatter.get("platforms") +def skill_matches_platform_list(platforms: Any) -> bool: + """Return True when *platforms* is compatible with the current OS.""" if not platforms: return True if not isinstance(platforms, list): @@ -204,6 +185,29 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool: return False +def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool: + """Return True when the skill is compatible with the current OS. + + Skills declare platform requirements via a top-level ``platforms`` list + in their YAML frontmatter:: + + platforms: [macos] # macOS only + platforms: [macos, linux] # macOS and Linux + + If the field is absent or empty the skill is compatible with **all** + platforms (backward-compatible default). + + Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on + older Pythons but became ``"android"`` on Python 3.13+. Termux is a + Linux userland riding on the Android kernel, so skills tagged + ``linux`` are treated as compatible in Termux regardless of which + ``sys.platform`` value Python reports. Individual Linux commands + inside a skill may still misbehave (no systemd, BusyBox utils, no + apt/dnf, etc.) but that is on the skill, not on platform gating. + """ + return skill_matches_platform_list(frontmatter.get("platforms")) + + # ── Environment matching ────────────────────────────────────────────────── # Recognized environment tags and how each is detected. An environment tag is @@ -787,8 +791,9 @@ def iter_skill_index_files(skills_dir: Path, filename: str): ``SKILL.md`` files, but they are progressive-disclosure data loaded through ``skill_view(..., file_path=...)`` rather than active skill roots. """ - matches = [] - for root, dirs, files in os.walk(skills_dir, followlinks=True): + skills_dir_str = str(skills_dir) + matches: list[str] = [] + for root, dirs, files in os.walk(skills_dir_str, followlinks=True): has_skill_md = "SKILL.md" in files dirs[:] = [ d @@ -797,9 +802,9 @@ def iter_skill_index_files(skills_dir: Path, filename: str): and not (has_skill_md and d in SKILL_SUPPORT_DIRS) ] if filename in files: - matches.append(Path(root) / filename) - for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))): - yield path + matches.append(os.path.join(root, filename)) + for path in sorted(matches): + yield Path(path) # ── Namespace helpers for plugin-provided skills ─────────────────────────── diff --git a/tests/agent/test_skill_utils.py b/tests/agent/test_skill_utils.py index 347d1b667c7a..177e99a59712 100644 --- a/tests/agent/test_skill_utils.py +++ b/tests/agent/test_skill_utils.py @@ -12,6 +12,7 @@ from agent.skill_utils import ( iter_skill_index_files, resolve_skill_config_values, skill_matches_platform, + skill_matches_platform_list, ) @@ -266,6 +267,7 @@ class TestSkillMatchesPlatformTermux: "agent.skill_utils.is_termux", return_value=True ): assert skill_matches_platform(fm) is True + assert skill_matches_platform_list(fm["platforms"]) is True def test_linux_macos_windows_skill_loads_on_termux(self): # The common "[linux, macos, windows]" tag used by github-*, @@ -275,6 +277,7 @@ class TestSkillMatchesPlatformTermux: "agent.skill_utils.is_termux", return_value=True ): assert skill_matches_platform(fm) is True + assert skill_matches_platform_list(fm["platforms"]) is True def test_linux_skill_loads_on_termux_linux_platform(self): # Pre-3.13 Termux reports sys.platform == "linux" already — this @@ -284,6 +287,7 @@ class TestSkillMatchesPlatformTermux: "agent.skill_utils.is_termux", return_value=True ): assert skill_matches_platform(fm) is True + assert skill_matches_platform_list(fm["platforms"]) is True def test_macos_only_skill_still_excluded_on_termux(self): # macOS-only skills (apple-notes, imessage, ...) should NOT load @@ -293,6 +297,7 @@ class TestSkillMatchesPlatformTermux: "agent.skill_utils.is_termux", return_value=True ): assert skill_matches_platform(fm) is False + assert skill_matches_platform_list(fm["platforms"]) is False def test_windows_only_skill_still_excluded_on_termux(self): fm = {"platforms": ["windows"]} @@ -300,6 +305,7 @@ class TestSkillMatchesPlatformTermux: "agent.skill_utils.is_termux", return_value=True ): assert skill_matches_platform(fm) is False + assert skill_matches_platform_list(fm["platforms"]) is False def test_explicit_termux_or_android_tag_matches(self): # Skills can also opt in explicitly via platforms:[termux] or @@ -309,6 +315,8 @@ class TestSkillMatchesPlatformTermux: ): assert skill_matches_platform({"platforms": ["termux"]}) is True assert skill_matches_platform({"platforms": ["android"]}) is True + assert skill_matches_platform_list(["termux"]) is True + assert skill_matches_platform_list(["android"]) is True def test_non_termux_android_does_not_widen(self): # If we're somehow on a plain Android Python (not Termux), don't @@ -318,6 +326,7 @@ class TestSkillMatchesPlatformTermux: "agent.skill_utils.is_termux", return_value=False ): assert skill_matches_platform(fm) is False + assert skill_matches_platform_list(fm["platforms"]) is False def test_linux_skill_on_real_linux_unaffected(self): # The non-Termux Linux path must not change. @@ -326,6 +335,7 @@ class TestSkillMatchesPlatformTermux: "agent.skill_utils.is_termux", return_value=False ): assert skill_matches_platform(fm) is True + assert skill_matches_platform_list(fm["platforms"]) is True def test_macos_skill_on_real_macos_unaffected(self): fm = {"platforms": ["macos"]} @@ -333,6 +343,7 @@ class TestSkillMatchesPlatformTermux: "agent.skill_utils.is_termux", return_value=False ): assert skill_matches_platform(fm) is True + assert skill_matches_platform_list(fm["platforms"]) is True class TestNormalizeSkillLookupName: From 449706cb5219257e2028ace22e7870cbb2bf3760 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:14:45 +0530 Subject: [PATCH 264/610] chore: add dexhunter to AUTHOR_MAP (PR #60339 salvage) --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 33ffb025c59b..db190822baeb 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1932,6 +1932,7 @@ AUTHOR_MAP = { "sebastianlutycz@users.noreply.github.com": "sebastianlutycz", # PR #39140 salvage (descendant CTE); bare noreply (no NNN+ prefix) needs explicit mapping "wafy.081107@gmail.com": "mahdiwafy", # PR #60347 salvage (session messages pagination) "codeforgenet@icloud.com": "CodeForgeNet", # PR #47437 salvage (compact_rows blob skip) + "i@dex.moe": "dexhunter", # PR #60339 salvage (skills snapshot manifest speedup) } From 2a293319b17b1cbd6c3d618f696b5a5e3aae7c71 Mon Sep 17 00:00:00 2001 From: Tosko4 <1294707+Tosko4@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:59:25 +0200 Subject: [PATCH 265/610] fix: run CLI new-session memory flush off-thread (cherry picked from commit 3e82a861e66aa773b8e1dd8f59e951b37c1396fb) --- cli.py | 40 +++++++++++++++-- run_agent.py | 11 ++++- tests/cli/test_cli_new_session.py | 44 +++++++++++++++++++ ...st_commit_memory_session_context_engine.py | 13 ++++++ 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/cli.py b/cli.py index 63b0240ec088..0fa4858236da 100644 --- a/cli.py +++ b/cli.py @@ -4131,6 +4131,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # Background task tracking: {task_id: threading.Thread} self._background_tasks: Dict[str, threading.Thread] = {} self._background_task_counter = 0 + self._last_session_boundary_thread: Optional[threading.Thread] = None def _claim_active_session(self, surface: str = "cli", *, stderr: bool = False) -> bool: """Claim a global active-session slot for this CLI process.""" @@ -6956,17 +6957,50 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): ) return False + def _launch_session_boundary_memory_flush( + self, + history_snapshot: list, + *, + session_id: Optional[str] = None, + ) -> None: + """Kick old-session memory extraction off-thread so /new is responsive.""" + self._last_session_boundary_thread = None + agent = getattr(self, "agent", None) + if not agent or not history_snapshot: + return + + def _run_boundary_flush() -> None: + if hasattr(agent, "commit_memory_session"): + try: + agent.commit_memory_session(history_snapshot, session_id=session_id) + except Exception: + pass + + thread = threading.Thread( + target=_run_boundary_flush, + daemon=True, + name=f"session-boundary-flush-{(session_id or 'unknown')[:24]}", + ) + self._last_session_boundary_thread = thread + thread.start() + def new_session(self, silent=False, title=None): """Start a fresh session with a new session ID and cleared agent state.""" + old_session_id = self.session_id if self.agent and self.conversation_history: - # Trigger memory extraction on the old session before session_id rotates. - self.agent.commit_memory_session(self.conversation_history) + # Trigger memory extraction on the old session without blocking + # session rotation. Snapshot the history and old session id first: + # the background thread may run after ``self.agent.session_id`` is + # updated to the new session below. + self._launch_session_boundary_memory_flush( + list(self.conversation_history), + session_id=old_session_id, + ) self._notify_session_boundary("on_session_finalize") elif self.agent: # First session or empty history — still finalize the old session self._notify_session_boundary("on_session_finalize") - 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 diff --git a/run_agent.py b/run_agent.py index 6fb7e0903e5c..d5b751073e9f 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3300,11 +3300,18 @@ class AIAgent: except Exception: pass - def commit_memory_session(self, messages: list = None) -> None: + def commit_memory_session( + self, + messages: list = None, + session_id: Optional[str] = None, + ) -> None: """Trigger end-of-session extraction without tearing providers down. Called when session_id rotates (e.g. /new, context compression); providers keep their state and continue running under the old session_id — they just flush pending extraction now.""" + ended_session_id = ( + session_id if session_id is not None else (self.session_id or "") + ) if self._memory_manager: try: self._memory_manager.on_session_end(messages or []) @@ -3319,7 +3326,7 @@ class AIAgent: if hasattr(self, "context_compressor") and self.context_compressor: try: self.context_compressor.on_session_end( - self.session_id or "", + ended_session_id, messages or [], ) except Exception: diff --git a/tests/cli/test_cli_new_session.py b/tests/cli/test_cli_new_session.py index c56ab63cf249..4bfb1d1ba357 100644 --- a/tests/cli/test_cli_new_session.py +++ b/tests/cli/test_cli_new_session.py @@ -156,6 +156,10 @@ def test_new_command_creates_real_fresh_session_and_resets_agent_state(tmp_path) cli.process_command("/new") + boundary_thread = getattr(cli, "_last_session_boundary_thread", None) + if boundary_thread is not None: + boundary_thread.join(timeout=1) + assert cli.session_id != old_session_id old_session = cli._session_db.get_session(old_session_id) @@ -175,6 +179,46 @@ def test_new_command_creates_real_fresh_session_and_resets_agent_state(tmp_path) cli.agent._invalidate_system_prompt.assert_called_once() +def test_new_session_dispatches_memory_flush_on_background_thread(tmp_path): + cli = _prepare_cli_with_active_session(tmp_path) + old_session_id = cli.session_id + + import cli as _cli_mod + + started = {} + + class _DummyThread: + def __init__(self, *, target=None, args=(), kwargs=None, daemon=None, name=None): + started["target"] = target + started["args"] = args + started["kwargs"] = kwargs or {} + started["daemon"] = daemon + started["name"] = name + started["started"] = False + + def start(self): + started["started"] = True + + def join(self, timeout=None): + return None + + with patch.object(_cli_mod.threading, "Thread", _DummyThread): + cli.process_command("/new") + + assert started["started"] is True + assert callable(started["target"]) + assert started["daemon"] is True + assert started["name"] == f"session-boundary-flush-{old_session_id[:24]}" + cli.agent.commit_memory_session.assert_not_called() + + started["target"]() + + cli.agent.commit_memory_session.assert_called_once_with( + [{"role": "user", "content": "hello"}], + session_id=old_session_id, + ) + + def test_new_command_rotates_hermes_session_id_env_and_context(tmp_path): from gateway.session_context import _VAR_MAP, get_session_env diff --git a/tests/run_agent/test_commit_memory_session_context_engine.py b/tests/run_agent/test_commit_memory_session_context_engine.py index 307814891a22..956cf5117bef 100644 --- a/tests/run_agent/test_commit_memory_session_context_engine.py +++ b/tests/run_agent/test_commit_memory_session_context_engine.py @@ -56,6 +56,19 @@ def test_commit_memory_session_with_no_messages_passes_empty_list(): ctx.on_session_end.assert_called_once_with("sess-7", []) +def test_commit_memory_session_can_finalize_explicit_old_session_id(): + """Async callers may run after agent.session_id already changed.""" + mm = MagicMock() + ctx = MagicMock() + agent = _make_minimal_agent(mm, ctx, session_id="new-session") + + msgs = [{"role": "user", "content": "old turn"}] + agent.commit_memory_session(msgs, session_id="old-session") + + mm.on_session_end.assert_called_once_with(msgs) + ctx.on_session_end.assert_called_once_with("old-session", msgs) + + def test_commit_memory_session_no_memory_manager_still_notifies_context_engine(): """If only the context engine is configured, it still gets the hook.""" ctx = MagicMock() From d8bc4f242f6e127a330afedb2ecdf210f3946316 Mon Sep 17 00:00:00 2001 From: Kshitij Kapoor Date: Thu, 9 Jul 2026 02:48:16 +0530 Subject: [PATCH 266/610] =?UTF-8?q?fix:=20serialize=20/new=20end=E2=86=92s?= =?UTF-8?q?witch=20boundary=20on=20the=20memory=20manager=20worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep review of the cherry-picked #16454 found the ad-hoc flush thread raced new_session()'s inline on_session_switch(reset=True): memory providers key off internal _session_id state (MemoryManager.on_session_end takes no session id), so a late off-thread extraction ran against post-rotation bindings — misattributing the old transcript to the new session id, double-ingesting the old turn buffer (supermemory), or double-committing (openviking already async-finalizes in on_session_switch). Redesign: new MemoryManager.commit_session_boundary_async queues on_session_end + on_session_switch as ONE task on the manager's existing single-worker background executor (the same worker sync_all already uses). This preserves the strict end→switch ordering providers depend on, serializes against per-turn syncs FIFO, keeps /new non-blocking, and degrades to inline (pre-#16454 behavior) when the executor is unavailable. No ad-hoc threads; no per-provider changes needed. The context-engine on_session_end half stays synchronous in _launch_session_boundary_memory_flush (cheap, must land before reset_session_state rebinds the engine). Exit durability: _run_cleanup calls the manager's existing flush_pending(timeout=10) barrier before shutdown, so '/new then quit' doesn't drop the queued extraction (shutdown_all's own drain is ~5s and cancels queued tasks). Bounded well inside the 30s exit watchdog. Tests: ordering invariant with slow (LLM-like) extraction, FIFO serialization vs sync_all, switch-fires-even-if-end-raises, no-provider no-op, CLI snapshot handoff + inline-switch fallback, sync engine boundary, cleanup flush_pending. --- agent/memory_manager.py | 50 +++++++++ cli.py | 88 +++++++++++---- tests/agent/test_memory_boundary_commit.py | 124 +++++++++++++++++++++ tests/cli/test_cli_new_session.py | 109 ++++++++++++------ 4 files changed, 314 insertions(+), 57 deletions(-) create mode 100644 tests/agent/test_memory_boundary_commit.py diff --git a/agent/memory_manager.py b/agent/memory_manager.py index c8b80a1514e2..342656b42928 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -783,6 +783,56 @@ class MemoryManager: exc_info=True, ) + def commit_session_boundary_async( + self, + messages: List[Dict[str, Any]], + *, + new_session_id: str, + parent_session_id: str = "", + reason: str = "new_session", + ) -> None: + """Queue old-session extraction + provider rebinding as ONE serialized task. + + Session rotation (/new) must deliver ``on_session_end`` (end-of-session + extraction — an LLM-bound call that can take seconds) strictly BEFORE + ``on_session_switch`` (which rebinds provider-internal ``_session_id`` / + turn buffers to the new session). Running extraction inline blocked the + /new command for the whole LLM round-trip (#16454); running it on an + ad-hoc thread raced the inline switch — providers key off internal + state, so a late ``on_session_end`` ran against post-switch bindings + (transcript misattributed to the new session id, double-ingest of the + old turn buffer, new-session buffers cleared). + + Submitting BOTH hooks as one task on the manager's single background + worker gives both properties at a single chokepoint: the caller returns + immediately, and the worker's FIFO order serializes end→switch against + every other provider write (per-turn ``sync_all``, prefetches), which + already share the same worker. If the executor is unavailable, + ``_submit_background`` degrades to inline execution — the pre-#16454 + synchronous behavior, slow but correct. + """ + providers = list(self._providers) + if not providers: + return + snapshot = list(messages or []) + + def _run() -> None: + try: + self.on_session_end(snapshot) + except Exception as e: # pragma: no cover - on_session_end guards per-provider + logger.warning("Session-boundary extraction failed: %s", e) + try: + self.on_session_switch( + new_session_id, + parent_session_id=parent_session_id, + reset=True, + reason=reason, + ) + except Exception as e: # pragma: no cover - on_session_switch guards per-provider + logger.warning("Session-boundary switch failed: %s", e) + + self._submit_background(_run) + def on_session_switch( self, new_session_id: str, diff --git a/cli.py b/cli.py index 0fa4858236da..c394f5545f63 100644 --- a/cli.py +++ b/cli.py @@ -1110,6 +1110,19 @@ def _run_cleanup(*, notify_session_finalize: bool = True): ) try: if _active_agent_ref and hasattr(_active_agent_ref, 'shutdown_memory_provider'): + # A /new shortly before exit leaves its end→switch boundary task + # (old-session extraction, LLM-bound) queued on the memory + # manager's serialized worker. shutdown_all()'s drain only waits + # ~5s and cancels queued tasks, so give pending work a bounded + # head start via the manager's own barrier — otherwise a + # "/new then quit" silently drops the old session's extraction. + # The 30s exit watchdog remains the hard backstop. + _mm = getattr(_active_agent_ref, '_memory_manager', None) + if _mm is not None and hasattr(_mm, 'flush_pending'): + try: + _mm.flush_pending(timeout=10) + except Exception: + pass # Forward the agent's own transcript so memory providers' # ``on_session_end`` hooks see the real conversation instead of # an empty list (#15165). ``_session_messages`` is set on @@ -4131,7 +4144,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # Background task tracking: {task_id: threading.Thread} self._background_tasks: Dict[str, threading.Thread] = {} self._background_task_counter = 0 - self._last_session_boundary_thread: Optional[threading.Thread] = None + # History snapshot staged by _launch_session_boundary_memory_flush for + # the /new end→switch boundary task (see new_session()). + self._session_boundary_snapshot: Optional[list] = None def _claim_active_session(self, surface: str = "cli", *, stderr: bool = False) -> bool: """Claim a global active-session slot for this CLI process.""" @@ -6963,26 +6978,38 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): *, session_id: Optional[str] = None, ) -> None: - """Kick old-session memory extraction off-thread so /new is responsive.""" - self._last_session_boundary_thread = None + """Stage old-session memory extraction so /new stays responsive. + + The context-engine ``on_session_end`` boundary is delivered + synchronously here: it is cheap (local state clear, no LLM call) and + ordering-sensitive — it must land before ``reset_session_state()`` + rebinds the engine to the new session. + + The memory-provider half (LLM-bound extraction, seconds) is NOT run + here. It is queued later in ``new_session()`` via + ``MemoryManager.commit_session_boundary_async`` as a single + end→switch task on the manager's serialized background worker, so + extraction can never race the provider rebinding (providers key off + internal ``_session_id`` state — a late ``on_session_end`` after + ``on_session_switch`` would misattribute the old transcript to the + new session). This method just snapshots the history for that call. + """ + self._session_boundary_snapshot = None agent = getattr(self, "agent", None) if not agent or not history_snapshot: return - def _run_boundary_flush() -> None: - if hasattr(agent, "commit_memory_session"): - try: - agent.commit_memory_session(history_snapshot, session_id=session_id) - except Exception: - pass + engine = getattr(agent, "context_compressor", None) + if engine is not None and hasattr(engine, "on_session_end"): + try: + engine.on_session_end(session_id or "", history_snapshot) + except Exception: + logger.debug( + "Context engine on_session_end failed at /new boundary", + exc_info=True, + ) - thread = threading.Thread( - target=_run_boundary_flush, - daemon=True, - name=f"session-boundary-flush-{(session_id or 'unknown')[:24]}", - ) - self._last_session_boundary_thread = thread - thread.start() + self._session_boundary_snapshot = list(history_snapshot) def new_session(self, silent=False, title=None): """Start a fresh session with a new session ID and cleared agent state.""" @@ -7088,15 +7115,33 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # per-session state (_session_turns, _turn_counter, _document_id). # Fires BEFORE the plugin on_session_reset hook (shell hooks only # see the new id; Python providers see the transition). See #6672. + # + # When the old session has history, end-of-session extraction + # (LLM-bound, seconds) and this switch are queued as ONE task on + # the memory manager's serialized worker — end strictly before + # switch, without blocking /new (#16454). With no history there + # is nothing to extract; switch inline as before. try: _mm = getattr(self.agent, "_memory_manager", None) if _mm is not None: - _mm.on_session_switch( - self.session_id, - parent_session_id=old_session_id or "", - reset=True, - reason="new_session", + _boundary_snapshot = getattr( + self, "_session_boundary_snapshot", None ) + if _boundary_snapshot: + self._session_boundary_snapshot = None + _mm.commit_session_boundary_async( + _boundary_snapshot, + new_session_id=self.session_id, + parent_session_id=old_session_id or "", + reason="new_session", + ) + else: + _mm.on_session_switch( + self.session_id, + parent_session_id=old_session_id or "", + reset=True, + reason="new_session", + ) except Exception: pass self._notify_session_boundary("on_session_reset") @@ -7108,7 +7153,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): print("(^_^)v New session started!") - def _consume_pending_resume_selection(self, text: str) -> bool: """Resolve a bare numeric reply that follows a bare ``/resume`` prompt. diff --git a/tests/agent/test_memory_boundary_commit.py b/tests/agent/test_memory_boundary_commit.py new file mode 100644 index 000000000000..521e199922c7 --- /dev/null +++ b/tests/agent/test_memory_boundary_commit.py @@ -0,0 +1,124 @@ +"""Tests for MemoryManager.commit_session_boundary_async. + +The /new session boundary must deliver on_session_end (old-session +extraction) strictly BEFORE on_session_switch (provider rebinding to the +new session), without blocking the caller. Both hooks run as one task on +the manager's single serialized background worker. +""" + +from __future__ import annotations + +import threading +import time +from typing import Any, Dict, List + +from agent.memory_manager import MemoryManager +from agent.memory_provider import MemoryProvider + + +class _RecordingProvider(MemoryProvider): + """Provider that records hook invocations with thread identity.""" + + def __init__(self, end_delay: float = 0.0): + self.calls: List[tuple] = [] + self._end_delay = end_delay + self._caller_thread_ids: List[int] = [] + + # Required ABC surface (minimal no-ops) + @property + def name(self) -> str: + return "recorder" + + def is_available(self) -> bool: + return True + + def get_tool_schemas(self) -> List[Dict[str, Any]]: + return [] + + def initialize(self, agent: Any = None, **kwargs) -> bool: # type: ignore[override] + return True + + def build_system_prompt(self) -> str: # type: ignore[override] + return "" + + def sync_turn(self, user_content: str, assistant_content: str, **kwargs) -> None: # type: ignore[override] + self.calls.append(("sync_turn", kwargs.get("session_id", ""))) + + def on_session_end(self, messages: List[Dict[str, Any]]) -> None: + if self._end_delay: + time.sleep(self._end_delay) + self._caller_thread_ids.append(threading.get_ident()) + self.calls.append(("end", list(messages))) + + def on_session_switch(self, new_session_id: str, **kwargs) -> None: + self.calls.append(("switch", new_session_id, kwargs.get("reset"))) + + +def _make_manager(provider: _RecordingProvider) -> MemoryManager: + mm = MemoryManager() + mm._providers.append(provider) # bypass add_provider validation for the stub + return mm + + +def test_boundary_commit_delivers_end_strictly_before_switch(): + """Even with a slow (LLM-like) extraction, switch waits for end.""" + provider = _RecordingProvider(end_delay=0.15) + mm = _make_manager(provider) + + msgs = [{"role": "user", "content": "old turn"}] + t0 = time.monotonic() + mm.commit_session_boundary_async( + msgs, new_session_id="new-sid", parent_session_id="old-sid" + ) + # Caller returns immediately — the slow extraction must not block /new. + assert time.monotonic() - t0 < 0.1 + + assert mm.flush_pending(timeout=5) + + kinds = [c[0] for c in provider.calls] + assert kinds == ["end", "switch"], f"ordering violated: {provider.calls}" + assert provider.calls[0] == ("end", msgs) + assert provider.calls[1] == ("switch", "new-sid", True) + # And it genuinely ran off the caller's thread. + assert provider._caller_thread_ids[0] != threading.get_ident() + + +def test_boundary_commit_serializes_against_turn_syncs(): + """The boundary task shares the single worker with sync_all — FIFO order + means a queued boundary can't interleave into a later turn's sync.""" + provider = _RecordingProvider(end_delay=0.05) + mm = _make_manager(provider) + + mm.commit_session_boundary_async( + [{"role": "user", "content": "old"}], + new_session_id="new-sid", + ) + mm.sync_all("next-session user msg", "assistant reply", session_id="new-sid") + + assert mm.flush_pending(timeout=5) + + kinds = [c[0] for c in provider.calls] + assert kinds == ["end", "switch", "sync_turn"], f"unexpected order: {provider.calls}" + + +def test_boundary_commit_switch_still_fires_when_end_raises(): + """A failing provider extraction must not strand providers on the old sid.""" + + class _ExplodingEndProvider(_RecordingProvider): + def on_session_end(self, messages): # type: ignore[override] + raise RuntimeError("provider extraction blew up") + + provider = _ExplodingEndProvider() + mm = _make_manager(provider) + + mm.commit_session_boundary_async([{"role": "user", "content": "x"}], new_session_id="new-sid") + assert mm.flush_pending(timeout=5) + + assert ("switch", "new-sid", True) in provider.calls + + +def test_boundary_commit_noop_without_providers(): + mm = MemoryManager() + # Must not create the executor or raise. + mm.commit_session_boundary_async([{"role": "user", "content": "x"}], new_session_id="s") + assert mm._sync_executor is None diff --git a/tests/cli/test_cli_new_session.py b/tests/cli/test_cli_new_session.py index 4bfb1d1ba357..c9f5cfea1ea6 100644 --- a/tests/cli/test_cli_new_session.py +++ b/tests/cli/test_cli_new_session.py @@ -156,10 +156,6 @@ def test_new_command_creates_real_fresh_session_and_resets_agent_state(tmp_path) cli.process_command("/new") - boundary_thread = getattr(cli, "_last_session_boundary_thread", None) - if boundary_thread is not None: - boundary_thread.join(timeout=1) - assert cli.session_id != old_session_id old_session = cli._session_db.get_session(old_session_id) @@ -179,44 +175,87 @@ def test_new_command_creates_real_fresh_session_and_resets_agent_state(tmp_path) cli.agent._invalidate_system_prompt.assert_called_once() -def test_new_session_dispatches_memory_flush_on_background_thread(tmp_path): +def test_new_session_queues_boundary_commit_with_snapshot(tmp_path): + """/new hands the OLD session's history + ids to the memory manager's + serialized boundary task instead of blocking on extraction inline.""" cli = _prepare_cli_with_active_session(tmp_path) old_session_id = cli.session_id + mm = MagicMock() + cli.agent._memory_manager = mm + + cli.process_command("/new") + + mm.commit_session_boundary_async.assert_called_once() + args, kwargs = mm.commit_session_boundary_async.call_args + assert args[0] == [{"role": "user", "content": "hello"}] + assert kwargs["new_session_id"] == cli.session_id + assert kwargs["parent_session_id"] == old_session_id + assert kwargs["reason"] == "new_session" + # The queued path replaces the inline switch — not both. + mm.on_session_switch.assert_not_called() + # Snapshot is consumed (a later /new without history must not re-send it). + assert cli._session_boundary_snapshot is None + + +def test_new_session_without_history_switches_inline(tmp_path): + """No old-session history → nothing to extract → plain inline switch.""" + cli = _prepare_cli_with_active_session(tmp_path) + cli.conversation_history = [] + + mm = MagicMock() + cli.agent._memory_manager = mm + + cli.process_command("/new") + + mm.commit_session_boundary_async.assert_not_called() + mm.on_session_switch.assert_called_once() + _, kwargs = mm.on_session_switch.call_args + assert kwargs["reset"] is True + + +def test_new_session_delivers_context_engine_boundary_synchronously(tmp_path): + """The context-engine on_session_end must fire during /new itself. + + It is cheap local state work and ordering-sensitive: it must land before + reset_session_state() rebinds the engine to the new session. The LLM-bound + provider extraction is what gets deferred, not this.""" + cli = _prepare_cli_with_active_session(tmp_path) + old_session_id = cli.session_id + + engine_calls = [] + cli.agent.context_compressor.on_session_end = ( + lambda sid, msgs: engine_calls.append((sid, list(msgs))) + ) + + cli.process_command("/new") + + assert engine_calls == [(old_session_id, [{"role": "user", "content": "hello"}])] + + +def test_run_cleanup_flushes_pending_memory_manager_work(tmp_path): + """A '/new then quit' must not drop the queued old-session extraction. + + _run_cleanup gives the manager's serialized worker a bounded drain via + flush_pending() before shutdown_all()'s short-fuse drain runs.""" import cli as _cli_mod - started = {} + agent = MagicMock() + mm = MagicMock() + mm.flush_pending.return_value = True + agent._memory_manager = mm + agent._session_messages = [] - class _DummyThread: - def __init__(self, *, target=None, args=(), kwargs=None, daemon=None, name=None): - started["target"] = target - started["args"] = args - started["kwargs"] = kwargs or {} - started["daemon"] = daemon - started["name"] = name - started["started"] = False + old_ref = _cli_mod._active_agent_ref + _cli_mod._active_agent_ref = agent + _cli_mod._cleanup_done = False + try: + _cli_mod._run_cleanup(notify_session_finalize=False) + finally: + _cli_mod._cleanup_done = True + _cli_mod._active_agent_ref = old_ref - def start(self): - started["started"] = True - - def join(self, timeout=None): - return None - - with patch.object(_cli_mod.threading, "Thread", _DummyThread): - cli.process_command("/new") - - assert started["started"] is True - assert callable(started["target"]) - assert started["daemon"] is True - assert started["name"] == f"session-boundary-flush-{old_session_id[:24]}" - cli.agent.commit_memory_session.assert_not_called() - - started["target"]() - - cli.agent.commit_memory_session.assert_called_once_with( - [{"role": "user", "content": "hello"}], - session_id=old_session_id, - ) + mm.flush_pending.assert_called_once_with(timeout=10) def test_new_command_rotates_hermes_session_id_env_and_context(tmp_path): From e0ed5dc9ed93aaa2d88ace82cf109fe0b33e4770 Mon Sep 17 00:00:00 2001 From: Kshitij Kapoor Date: Thu, 9 Jul 2026 03:14:56 +0530 Subject: [PATCH 267/610] refactor: address Phase-2 review findings on /new boundary handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Return the boundary snapshot from _launch_session_boundary_memory_flush as a local value instead of staging it on self._session_boundary_snapshot. The instance-attr handoff could leak (no memory manager configured) or mis-fire a stale snapshot on a later /new if an exception hit between staging and consumption. A local variable eliminates the class; the helper also returns None when no memory manager is configured so new_session takes the inline-switch path. - Drop the now-dead session_id kwarg from commit_memory_session: after the redesign no production caller passes it (gateway, TUI, compression all use the default), and speculative params are rejected per AGENTS.md. The explicit-old-session need is served by cli.py's direct engine call + commit_session_boundary_async. - Drop the dead providers snapshot in commit_session_boundary_async (only the emptiness check used it). - Tests updated accordingly (dead-kwarg test removed, snapshot assertion now covered by return-value contract). Phase-2 gates: 2a tests/cli 1048 passed + 6 memory files 137 passed; 2b programmatic live smoke 0.38ms non-blocking caller, end→switch→sync ordering verified; 2c structured 4-angle review — no Criticals, these warnings fixed. --- agent/memory_manager.py | 3 +- cli.py | 36 +++++++++---------- run_agent.py | 11 ++---- tests/cli/test_cli_new_session.py | 2 -- ...st_commit_memory_session_context_engine.py | 13 ------- 5 files changed, 21 insertions(+), 44 deletions(-) diff --git a/agent/memory_manager.py b/agent/memory_manager.py index 342656b42928..32dd8e288841 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -811,8 +811,7 @@ class MemoryManager: ``_submit_background`` degrades to inline execution — the pre-#16454 synchronous behavior, slow but correct. """ - providers = list(self._providers) - if not providers: + if not self._providers: return snapshot = list(messages or []) diff --git a/cli.py b/cli.py index c394f5545f63..069b3c616c1e 100644 --- a/cli.py +++ b/cli.py @@ -4144,9 +4144,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # Background task tracking: {task_id: threading.Thread} self._background_tasks: Dict[str, threading.Thread] = {} self._background_task_counter = 0 - # History snapshot staged by _launch_session_boundary_memory_flush for - # the /new end→switch boundary task (see new_session()). - self._session_boundary_snapshot: Optional[list] = None def _claim_active_session(self, surface: str = "cli", *, stderr: bool = False) -> bool: """Claim a global active-session slot for this CLI process.""" @@ -6977,7 +6974,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): history_snapshot: list, *, session_id: Optional[str] = None, - ) -> None: + ) -> Optional[list]: """Stage old-session memory extraction so /new stays responsive. The context-engine ``on_session_end`` boundary is delivered @@ -6986,18 +6983,20 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): rebinds the engine to the new session. The memory-provider half (LLM-bound extraction, seconds) is NOT run - here. It is queued later in ``new_session()`` via + here. The returned snapshot is handed by ``new_session()`` to ``MemoryManager.commit_session_boundary_async`` as a single end→switch task on the manager's serialized background worker, so extraction can never race the provider rebinding (providers key off internal ``_session_id`` state — a late ``on_session_end`` after ``on_session_switch`` would misattribute the old transcript to the - new session). This method just snapshots the history for that call. + new session). + + Returns the history snapshot to queue, or ``None`` when there is + nothing to extract (no agent / empty history / no memory manager). """ - self._session_boundary_snapshot = None agent = getattr(self, "agent", None) if not agent or not history_snapshot: - return + return None engine = getattr(agent, "context_compressor", None) if engine is not None and hasattr(engine, "on_session_end"): @@ -7009,17 +7008,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): exc_info=True, ) - self._session_boundary_snapshot = list(history_snapshot) + # No provider extraction to queue when no memory manager is + # configured — new_session() falls back to the inline switch path. + if getattr(agent, "_memory_manager", None) is None: + return None + return history_snapshot def new_session(self, silent=False, title=None): """Start a fresh session with a new session ID and cleared agent state.""" old_session_id = self.session_id + _boundary_snapshot = None if self.agent and self.conversation_history: - # Trigger memory extraction on the old session without blocking - # session rotation. Snapshot the history and old session id first: - # the background thread may run after ``self.agent.session_id`` is - # updated to the new session below. - self._launch_session_boundary_memory_flush( + # Deliver the context-engine boundary synchronously and get back + # the history snapshot for the deferred provider extraction — + # queued below (after rotation) so /new never blocks on the + # LLM-bound extraction call. + _boundary_snapshot = self._launch_session_boundary_memory_flush( list(self.conversation_history), session_id=old_session_id, ) @@ -7124,11 +7128,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): try: _mm = getattr(self.agent, "_memory_manager", None) if _mm is not None: - _boundary_snapshot = getattr( - self, "_session_boundary_snapshot", None - ) if _boundary_snapshot: - self._session_boundary_snapshot = None _mm.commit_session_boundary_async( _boundary_snapshot, new_session_id=self.session_id, diff --git a/run_agent.py b/run_agent.py index d5b751073e9f..6fb7e0903e5c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3300,18 +3300,11 @@ class AIAgent: except Exception: pass - def commit_memory_session( - self, - messages: list = None, - session_id: Optional[str] = None, - ) -> None: + def commit_memory_session(self, messages: list = None) -> None: """Trigger end-of-session extraction without tearing providers down. Called when session_id rotates (e.g. /new, context compression); providers keep their state and continue running under the old session_id — they just flush pending extraction now.""" - ended_session_id = ( - session_id if session_id is not None else (self.session_id or "") - ) if self._memory_manager: try: self._memory_manager.on_session_end(messages or []) @@ -3326,7 +3319,7 @@ class AIAgent: if hasattr(self, "context_compressor") and self.context_compressor: try: self.context_compressor.on_session_end( - ended_session_id, + self.session_id or "", messages or [], ) except Exception: diff --git a/tests/cli/test_cli_new_session.py b/tests/cli/test_cli_new_session.py index c9f5cfea1ea6..e869b13c851f 100644 --- a/tests/cli/test_cli_new_session.py +++ b/tests/cli/test_cli_new_session.py @@ -194,8 +194,6 @@ def test_new_session_queues_boundary_commit_with_snapshot(tmp_path): assert kwargs["reason"] == "new_session" # The queued path replaces the inline switch — not both. mm.on_session_switch.assert_not_called() - # Snapshot is consumed (a later /new without history must not re-send it). - assert cli._session_boundary_snapshot is None def test_new_session_without_history_switches_inline(tmp_path): diff --git a/tests/run_agent/test_commit_memory_session_context_engine.py b/tests/run_agent/test_commit_memory_session_context_engine.py index 956cf5117bef..307814891a22 100644 --- a/tests/run_agent/test_commit_memory_session_context_engine.py +++ b/tests/run_agent/test_commit_memory_session_context_engine.py @@ -56,19 +56,6 @@ def test_commit_memory_session_with_no_messages_passes_empty_list(): ctx.on_session_end.assert_called_once_with("sess-7", []) -def test_commit_memory_session_can_finalize_explicit_old_session_id(): - """Async callers may run after agent.session_id already changed.""" - mm = MagicMock() - ctx = MagicMock() - agent = _make_minimal_agent(mm, ctx, session_id="new-session") - - msgs = [{"role": "user", "content": "old turn"}] - agent.commit_memory_session(msgs, session_id="old-session") - - mm.on_session_end.assert_called_once_with(msgs) - ctx.on_session_end.assert_called_once_with("old-session", msgs) - - def test_commit_memory_session_no_memory_manager_still_notifies_context_engine(): """If only the context engine is configured, it still gets the hook.""" ctx = MagicMock() From 6d6521025297493c5811d448689e5e9c0c75b7c2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 8 Jul 2026 17:08:21 -0500 Subject: [PATCH 268/610] feat(desktop): group tool calls across text-less assistant messages The model often emits a follow-up batch of tool calls as its own assistant message with no prose or reasoning. On screen those rows look like one continuous run, but assistant-ui only groups tool calls within a single message, so the auto-scrolling tool window never triggered on them (e.g. two batches of two searches read as 2 + 2, never reaching the threshold). Coalesce each settled tool-only assistant message into the preceding assistant message in the render pipeline so its calls join that message's tool group. Render-only (never touches the $messages store) and settle-only (pending messages are skipped) so a live turn is never merged/un-merged mid-stream; merged results are cached by source identity so a stable turn yields stable objects with no re-render churn. --- apps/desktop/src/app/chat/index.tsx | 11 +++++- apps/desktop/src/lib/chat-runtime.ts | 58 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 74eb8df86613..bece9b49152a 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -19,7 +19,13 @@ import { ErrorState } from '@/components/ui/error-state' import { getGlobalModelOptions, type HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' import type { ChatMessage } from '@/lib/chat-messages' -import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime' +import { + coalesceToolOnlyAssistants, + createToolMergeCache, + quickModelOptions, + sessionTitle, + toRuntimeMessage +} from '@/lib/chat-runtime' import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' import { cn } from '@/lib/utils' import type { ComposerAttachment } from '@/store/composer' @@ -201,6 +207,7 @@ function ChatRuntimeBoundary({ const storeMessages = useStore($messages) const messages = suppressMessages ? NO_MESSAGES : storeMessages const runtimeMessageCacheRef = useRef(new WeakMap()) + const toolMergeCacheRef = useRef(createToolMergeCache()) const runtimeMessageRepository = useMemo(() => { const items: { message: ThreadMessage; parentId: string | null }[] = [] @@ -208,7 +215,7 @@ function ChatRuntimeBoundary({ let visibleParentId: string | null = null let headId: string | null = null - for (const message of messages) { + for (const message of coalesceToolOnlyAssistants(messages, toolMergeCacheRef.current)) { let parentId = visibleParentId if (message.role === 'assistant' && message.branchGroupId) { diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 06d8e4c3265b..d315617d2ace 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -383,3 +383,61 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage { } } as ThreadMessage } + +export type ToolMergeCache = WeakMap< + ChatMessage, + { merged: ChatMessage; parts: ChatMessagePart[]; prev: ChatMessage; prevParts: ChatMessagePart[] } +> + +export function createToolMergeCache(): ToolMergeCache { + return new WeakMap() +} + +// A settled assistant message with only tool calls — no prose, no reasoning. +// The model routinely emits a follow-up batch of calls as its own text-less +// message; on screen it looks like one continuous run, but assistant-ui can't +// group tool calls across a message boundary. +function isToolOnlyAssistant(message: ChatMessage): boolean { + return ( + message.role === 'assistant' && + !message.pending && + !message.error && + !message.hidden && + message.parts.length > 0 && + message.parts.every(part => part.type === 'tool-call') + ) +} + +/** + * Fold each settled tool-only assistant message into the preceding assistant + * message so its calls join that message's tool group (and can collapse into + * the auto-scrolling window). Render-only — never mutates the `$messages` store + * — and settle-only: pending messages are left alone, so a live turn is never + * merged/un-merged mid-stream. `cache` keys merged results by source identity, + * so a stable turn yields stable merged objects (no re-render churn). + */ +export function coalesceToolOnlyAssistants(messages: ChatMessage[], cache: ToolMergeCache): ChatMessage[] { + const out: ChatMessage[] = [] + + for (const message of messages) { + const prev = out.at(-1) + + if (prev && prev.role === 'assistant' && !prev.pending && !prev.hidden && isToolOnlyAssistant(message)) { + const cached = cache.get(message) + + const merged = + cached && cached.prev === prev && cached.prevParts === prev.parts && cached.parts === message.parts + ? cached.merged + : { ...prev, parts: [...prev.parts, ...message.parts] } + + cache.set(message, { merged, parts: message.parts, prev, prevParts: prev.parts }) + out[out.length - 1] = merged + + continue + } + + out.push(message) + } + + return out +} From 39d09453f95e8aefc0c97e5d9b30ff341cae9ed8 Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 29 Jun 2026 16:31:36 -0400 Subject: [PATCH 269/610] feat(desktop): ts-ify everything --- .../src-tauri/src/bootstrap.rs | 2 +- .../src-tauri/src/events.rs | 2 +- .../src-tauri/src/install_script.rs | 4 +- .../src-tauri/src/paths.rs | 2 +- .../src-tauri/src/powershell.rs | 6 +- apps/desktop/README.md | 2 +- ...mmand.test.cjs => backend-command.test.ts} | 7 +- ...backend-command.cjs => backend-command.ts} | 17 +- ...ckend-env.test.cjs => backend-env.test.ts} | 14 +- .../{backend-env.cjs => backend-env.ts} | 29 +- ...probes.test.cjs => backend-probes.test.ts} | 17 +- .../{backend-probes.cjs => backend-probes.ts} | 34 +- ...d-ready.test.cjs => backend-ready.test.ts} | 41 +- .../{backend-ready.cjs => backend-ready.ts} | 43 +- ...rm.test.cjs => bootstrap-platform.test.ts} | 36 +- ...rap-platform.cjs => bootstrap-platform.ts} | 33 +- ...nner.test.cjs => bootstrap-runner.test.ts} | 25 +- ...otstrap-runner.cjs => bootstrap-runner.ts} | 170 ++- ...fig.test.cjs => connection-config.test.ts} | 25 +- ...ection-config.cjs => connection-config.ts} | 60 +- ...token.test.cjs => dashboard-token.test.ts} | 20 +- ...dashboard-token.cjs => dashboard-token.ts} | 25 +- ...all.test.cjs => desktop-uninstall.test.ts} | 23 +- ...top-uninstall.cjs => desktop-uninstall.ts} | 52 +- .../{embed-referer.cjs => embed-referer.ts} | 8 +- ...-read-dir.test.cjs => fs-read-dir.test.ts} | 30 +- .../{fs-read-dir.cjs => fs-read-dir.ts} | 17 +- ...robe.test.cjs => gateway-ws-probe.test.ts} | 25 +- ...teway-ws-probe.cjs => gateway-ws-probe.ts} | 55 +- .../{git-repo-scan.cjs => git-repo-scan.ts} | 16 +- ...ew-ops.test.cjs => git-review-ops.test.ts} | 8 +- .../{git-review-ops.cjs => git-review-ops.ts} | 56 +- .../{git-root.test.cjs => git-root.test.ts} | 16 +- .../electron/{git-root.cjs => git-root.ts} | 15 +- ...-ops.test.cjs => git-worktree-ops.test.ts} | 20 +- ...t-worktree-ops.cjs => git-worktree-ops.ts} | 19 +- .../{hardening.test.cjs => hardening.test.ts} | 39 +- .../electron/{hardening.cjs => hardening.ts} | 45 +- ...dow.test.cjs => link-title-window.test.ts} | 11 +- ...-title-window.cjs => link-title-window.ts} | 23 +- apps/desktop/electron/{main.cjs => main.ts} | 1342 +++++++++++------ ...est.test.cjs => oauth-net-request.test.ts} | 9 +- ...h-net-request.cjs => oauth-net-request.ts} | 6 +- ...test.cjs => oauth-session-request.test.ts} | 13 +- .../electron/{preload.cjs => preload.ts} | 21 +- ...est.cjs => profile-delete-respawn.test.ts} | 14 +- ...ndows.test.cjs => session-windows.test.ts} | 13 +- ...session-windows.cjs => session-windows.ts} | 16 +- ...est.cjs => titlebar-overlay-width.test.ts} | 11 +- ...ay-width.cjs => titlebar-overlay-width.ts} | 14 +- ...te-count.test.cjs => update-count.test.ts} | 8 +- .../{update-count.cjs => update-count.ts} | 8 +- ...-marker.test.cjs => update-marker.test.ts} | 31 +- .../{update-marker.cjs => update-marker.ts} | 34 +- ...ebuild.test.cjs => update-rebuild.test.ts} | 19 +- .../{update-rebuild.cjs => update-rebuild.ts} | 6 +- ...aunch.test.cjs => update-relaunch.test.ts} | 39 +- ...update-relaunch.cjs => update-relaunch.ts} | 79 +- ...-remote.test.cjs => update-remote.test.ts} | 18 +- .../{update-remote.cjs => update-remote.ts} | 26 +- ...ce.test.cjs => vscode-marketplace.test.ts} | 9 +- ...-marketplace.cjs => vscode-marketplace.ts} | 20 +- ...ow-state.test.cjs => window-state.test.ts} | 18 +- .../{window-state.cjs => window-state.ts} | 57 +- ...test.cjs => windows-child-process.test.ts} | 27 +- ....cjs => windows-hermes-resolution.test.ts} | 23 +- ...-env.test.cjs => windows-user-env.test.ts} | 14 +- ...ndows-user-env.cjs => windows-user-env.ts} | 31 +- ...ace-cwd.test.cjs => workspace-cwd.test.ts} | 12 +- .../{workspace-cwd.cjs => workspace-cwd.ts} | 8 +- ...e.test.cjs => wsl-clipboard-image.test.ts} | 31 +- ...board-image.cjs => wsl-clipboard-image.ts} | 22 +- .../electron/{zoom.test.cjs => zoom.test.ts} | 7 +- apps/desktop/electron/{zoom.cjs => zoom.ts} | 23 +- apps/desktop/eslint.config.mjs | 4 +- apps/desktop/package.json | 40 +- .../{after-pack.cjs => after-pack.mjs} | 12 +- ...t-dist-built.cjs => assert-dist-built.mjs} | 31 +- ...lt.test.cjs => assert-dist-built.test.mjs} | 12 +- apps/desktop/scripts/assert-root-install.cjs | 13 - apps/desktop/scripts/assert-root-install.mjs | 11 + .../{before-build.cjs => before-build.mjs} | 4 +- .../{before-pack.cjs => before-pack.mjs} | 60 +- ...ore-pack.test.cjs => before-pack.test.mjs} | 13 +- apps/desktop/scripts/bundle-electron-main.mjs | 60 + ...ize-artifact.cjs => notarize-artifact.mjs} | 18 +- .../scripts/{notarize.cjs => notarize.mjs} | 10 +- ... => patch-electron-builder-mac-binary.mjs} | 6 +- apps/desktop/scripts/rebuild-native.mjs | 22 + ...n-builder.cjs => run-electron-builder.mjs} | 11 +- ...-exe-identity.cjs => set-exe-identity.mjs} | 42 +- apps/desktop/scripts/stage-native-deps.cjs | 283 ---- apps/desktop/scripts/stage-native-deps.mjs | 137 ++ apps/desktop/scripts/test-desktop.mjs | 58 +- apps/desktop/scripts/utils.mjs | 8 + ...-build-stamp.cjs => write-build-stamp.mjs} | 22 +- apps/desktop/src/app/desktop-controller.tsx | 2 +- .../hooks/use-prompt-actions/index.test.tsx | 2 +- .../hooks/use-prompt-actions/submit.ts | 2 +- .../session/hooks/use-prompt-actions/utils.ts | 2 +- .../components/desktop-install-overlay.tsx | 6 +- .../src/components/pet/floating-pet.tsx | 2 +- apps/desktop/src/global.d.ts | 2 +- apps/desktop/src/hermes.ts | 2 +- apps/desktop/src/lib/media.ts | 2 +- apps/desktop/src/store/pet-overlay.ts | 8 +- apps/desktop/src/store/windows.ts | 2 +- apps/desktop/src/store/zoom.ts | 2 +- apps/desktop/src/themes/install.ts | 2 +- apps/desktop/tsconfig.electron.json | 20 + apps/desktop/tsconfig.json | 4 +- nix/desktop.nix | 96 +- package-lock.json | 47 +- scripts/install.ps1 | 12 +- scripts/install.sh | 2 +- tests/test_desktop_electron_pin.py | 41 +- 116 files changed, 2500 insertions(+), 1756 deletions(-) rename apps/desktop/electron/{backend-command.test.cjs => backend-command.test.ts} (91%) rename apps/desktop/electron/{backend-command.cjs => backend-command.ts} (87%) rename apps/desktop/electron/{backend-env.test.cjs => backend-env.test.ts} (94%) rename apps/desktop/electron/{backend-env.cjs => backend-env.ts} (89%) rename apps/desktop/electron/{backend-probes.test.cjs => backend-probes.test.ts} (90%) rename apps/desktop/electron/{backend-probes.cjs => backend-probes.ts} (87%) rename apps/desktop/electron/{backend-ready.test.cjs => backend-ready.test.ts} (92%) rename apps/desktop/electron/{backend-ready.cjs => backend-ready.ts} (90%) rename apps/desktop/electron/{bootstrap-platform.test.cjs => bootstrap-platform.test.ts} (72%) rename apps/desktop/electron/{bootstrap-platform.cjs => bootstrap-platform.ts} (78%) rename apps/desktop/electron/{bootstrap-runner.test.cjs => bootstrap-runner.test.ts} (94%) rename apps/desktop/electron/{bootstrap-runner.cjs => bootstrap-runner.ts} (90%) rename apps/desktop/electron/{connection-config.test.cjs => connection-config.test.ts} (98%) rename apps/desktop/electron/{connection-config.cjs => connection-config.ts} (90%) rename apps/desktop/electron/{dashboard-token.test.cjs => dashboard-token.test.ts} (93%) rename apps/desktop/electron/{dashboard-token.cjs => dashboard-token.ts} (92%) rename apps/desktop/electron/{desktop-uninstall.test.cjs => desktop-uninstall.test.ts} (97%) rename apps/desktop/electron/{desktop-uninstall.cjs => desktop-uninstall.ts} (93%) rename apps/desktop/electron/{embed-referer.cjs => embed-referer.ts} (92%) rename apps/desktop/electron/{fs-read-dir.test.cjs => fs-read-dir.test.ts} (97%) rename apps/desktop/electron/{fs-read-dir.cjs => fs-read-dir.ts} (87%) rename apps/desktop/electron/{gateway-ws-probe.test.cjs => gateway-ws-probe.test.ts} (89%) rename apps/desktop/electron/{gateway-ws-probe.cjs => gateway-ws-probe.ts} (87%) rename apps/desktop/electron/{git-repo-scan.cjs => git-repo-scan.ts} (93%) rename apps/desktop/electron/{git-review-ops.test.cjs => git-review-ops.test.ts} (78%) rename apps/desktop/electron/{git-review-ops.cjs => git-review-ops.ts} (93%) rename apps/desktop/electron/{git-root.test.cjs => git-root.test.ts} (80%) rename apps/desktop/electron/{git-root.cjs => git-root.ts} (75%) rename apps/desktop/electron/{git-worktree-ops.test.cjs => git-worktree-ops.test.ts} (95%) rename apps/desktop/electron/{git-worktree-ops.cjs => git-worktree-ops.ts} (96%) rename apps/desktop/electron/{hardening.test.cjs => hardening.test.ts} (95%) rename apps/desktop/electron/{hardening.cjs => hardening.ts} (89%) rename apps/desktop/electron/{link-title-window.test.cjs => link-title-window.test.ts} (95%) rename apps/desktop/electron/{link-title-window.cjs => link-title-window.ts} (80%) rename apps/desktop/electron/{main.cjs => main.ts} (94%) rename apps/desktop/electron/{oauth-net-request.test.cjs => oauth-net-request.test.ts} (78%) rename apps/desktop/electron/{oauth-net-request.cjs => oauth-net-request.ts} (87%) rename apps/desktop/electron/{oauth-session-request.test.cjs => oauth-session-request.test.ts} (75%) rename apps/desktop/electron/{preload.cjs => preload.ts} (98%) rename apps/desktop/electron/{profile-delete-respawn.test.cjs => profile-delete-respawn.test.ts} (89%) rename apps/desktop/electron/{session-windows.test.cjs => session-windows.test.ts} (97%) rename apps/desktop/electron/{session-windows.cjs => session-windows.ts} (90%) rename apps/desktop/electron/{titlebar-overlay-width.test.cjs => titlebar-overlay-width.test.ts} (90%) rename apps/desktop/electron/{titlebar-overlay-width.cjs => titlebar-overlay-width.ts} (80%) rename apps/desktop/electron/{update-count.test.cjs => update-count.test.ts} (94%) rename apps/desktop/electron/{update-count.cjs => update-count.ts} (90%) rename apps/desktop/electron/{update-marker.test.cjs => update-marker.test.ts} (85%) rename apps/desktop/electron/{update-marker.cjs => update-marker.ts} (87%) rename apps/desktop/electron/{update-rebuild.test.cjs => update-rebuild.test.ts} (84%) rename apps/desktop/electron/{update-rebuild.cjs => update-rebuild.ts} (92%) rename apps/desktop/electron/{update-relaunch.test.cjs => update-relaunch.test.ts} (95%) rename apps/desktop/electron/{update-relaunch.cjs => update-relaunch.ts} (89%) rename apps/desktop/electron/{update-remote.test.cjs => update-remote.test.ts} (91%) rename apps/desktop/electron/{update-remote.cjs => update-remote.ts} (79%) rename apps/desktop/electron/{vscode-marketplace.test.cjs => vscode-marketplace.test.ts} (95%) rename apps/desktop/electron/{vscode-marketplace.cjs => vscode-marketplace.ts} (98%) rename apps/desktop/electron/{window-state.test.cjs => window-state.test.ts} (96%) rename apps/desktop/electron/{window-state.cjs => window-state.ts} (82%) rename apps/desktop/electron/{windows-child-process.test.cjs => windows-child-process.test.ts} (88%) rename apps/desktop/electron/{windows-hermes-resolution.test.cjs => windows-hermes-resolution.test.ts} (85%) rename apps/desktop/electron/{windows-user-env.test.cjs => windows-user-env.test.ts} (94%) rename apps/desktop/electron/{windows-user-env.cjs => windows-user-env.ts} (83%) rename apps/desktop/electron/{workspace-cwd.test.cjs => workspace-cwd.test.ts} (77%) rename apps/desktop/electron/{workspace-cwd.cjs => workspace-cwd.ts} (78%) rename apps/desktop/electron/{wsl-clipboard-image.test.cjs => wsl-clipboard-image.test.ts} (92%) rename apps/desktop/electron/{wsl-clipboard-image.cjs => wsl-clipboard-image.ts} (90%) rename apps/desktop/electron/{zoom.test.cjs => zoom.test.ts} (89%) rename apps/desktop/electron/{zoom.cjs => zoom.ts} (64%) rename apps/desktop/scripts/{after-pack.cjs => after-pack.mjs} (81%) rename apps/desktop/scripts/{assert-dist-built.cjs => assert-dist-built.mjs} (74%) rename apps/desktop/scripts/{assert-dist-built.test.cjs => assert-dist-built.test.mjs} (91%) delete mode 100644 apps/desktop/scripts/assert-root-install.cjs create mode 100644 apps/desktop/scripts/assert-root-install.mjs rename apps/desktop/scripts/{before-build.cjs => before-build.mjs} (89%) rename apps/desktop/scripts/{before-pack.cjs => before-pack.mjs} (54%) rename apps/desktop/scripts/{before-pack.test.cjs => before-pack.test.mjs} (86%) create mode 100644 apps/desktop/scripts/bundle-electron-main.mjs rename apps/desktop/scripts/{notarize-artifact.cjs => notarize-artifact.mjs} (83%) rename apps/desktop/scripts/{notarize.cjs => notarize.mjs} (93%) rename apps/desktop/scripts/{patch-electron-builder-mac-binary.cjs => patch-electron-builder-mac-binary.mjs} (96%) create mode 100644 apps/desktop/scripts/rebuild-native.mjs rename apps/desktop/scripts/{run-electron-builder.cjs => run-electron-builder.mjs} (89%) rename apps/desktop/scripts/{set-exe-identity.cjs => set-exe-identity.mjs} (70%) delete mode 100644 apps/desktop/scripts/stage-native-deps.cjs create mode 100644 apps/desktop/scripts/stage-native-deps.mjs create mode 100644 apps/desktop/scripts/utils.mjs rename apps/desktop/scripts/{write-build-stamp.cjs => write-build-stamp.mjs} (86%) create mode 100644 apps/desktop/tsconfig.electron.json diff --git a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs index a8fcd656b8ab..1d70ec59a611 100644 --- a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs +++ b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs @@ -1,6 +1,6 @@ //! Bootstrap orchestration. //! -//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.cjs`. +//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.ts`. //! Drives install.ps1 / install.sh stage-by-stage, emits progress events //! over the Tauri `bootstrap` channel, writes a forensic log to //! HERMES_HOME/logs/bootstrap-.log. diff --git a/apps/bootstrap-installer/src-tauri/src/events.rs b/apps/bootstrap-installer/src-tauri/src/events.rs index e00105013bef..afadbf8e868a 100644 --- a/apps/bootstrap-installer/src-tauri/src/events.rs +++ b/apps/bootstrap-installer/src-tauri/src/events.rs @@ -1,6 +1,6 @@ //! Event types streamed from Rust → React. //! -//! These mirror `apps/desktop/electron/bootstrap-runner.cjs`'s event shape +//! These mirror `apps/desktop/electron/bootstrap-runner.ts`'s event shape //! 1:1 so the React installer code can be roughly identical to the Electron //! install-overlay we'll replace. //! diff --git a/apps/bootstrap-installer/src-tauri/src/install_script.rs b/apps/bootstrap-installer/src-tauri/src/install_script.rs index 217ee9fef5a7..67a114408f89 100644 --- a/apps/bootstrap-installer/src-tauri/src/install_script.rs +++ b/apps/bootstrap-installer/src-tauri/src/install_script.rs @@ -8,7 +8,7 @@ //! 3. Network: download from GitHub raw at a pinned commit or branch. //! Commit pins are immutable; branch pins are HEAD-tracking. //! -//! Mirrors `apps/desktop/electron/bootstrap-runner.cjs`'s `resolveInstallScript`, +//! Mirrors `apps/desktop/electron/bootstrap-runner.ts`'s `resolveInstallScript`, //! but the dev-checkout resolution is driven by an env var rather than the //! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant //! to live OUTSIDE any repo checkout. @@ -64,7 +64,7 @@ impl ScriptKind { } /// Validates a string looks like a git SHA (7+ hex chars). Mirrors -/// `STAMP_COMMIT_RE` from bootstrap-runner.cjs. +/// `STAMP_COMMIT_RE` from bootstrap-runner.ts. fn is_valid_commit(s: &str) -> bool { let len = s.len(); (7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit()) diff --git a/apps/bootstrap-installer/src-tauri/src/paths.rs b/apps/bootstrap-installer/src-tauri/src/paths.rs index 99ad16f6b883..7c64c91cf6ef 100644 --- a/apps/bootstrap-installer/src-tauri/src/paths.rs +++ b/apps/bootstrap-installer/src-tauri/src/paths.rs @@ -150,7 +150,7 @@ fn repair_macos_installer_helper(path: &Path) { fn repair_macos_installer_helper(_path: &Path) {} /// Where install.ps1 writes the bootstrap-complete marker (existence-only file -/// the Electron app also checks). Per main.cjs: +/// the Electron app also checks). Per main.ts: /// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete') /// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so /// this is a probe helper, not a definitive path. diff --git a/apps/bootstrap-installer/src-tauri/src/powershell.rs b/apps/bootstrap-installer/src-tauri/src/powershell.rs index f37a3c68b36d..04694c113b52 100644 --- a/apps/bootstrap-installer/src-tauri/src/powershell.rs +++ b/apps/bootstrap-installer/src-tauri/src/powershell.rs @@ -1,6 +1,6 @@ //! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh. //! -//! Port of `spawnPowerShell` from bootstrap-runner.cjs, with the same +//! Port of `spawnPowerShell` from bootstrap-runner.ts, with the same //! line-buffered stdout/stderr streaming + cancellation semantics. //! //! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File ' @@ -39,9 +37,11 @@ test('dashboardIndexUrl preserves dashboard path prefixes', () => { test('resolveServedDashboardToken uses the served token and logs when it differs', async () => { const logs = [] + const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { fetchText: async url => { assert.equal(url, 'http://127.0.0.1:9120/') + return '' }, rememberLog: line => logs.push(line) @@ -100,8 +100,9 @@ test('isForeignBackendToken only flags a mismatched token from a dead child', () [{ servedToken: null, spawnToken: 'mine', childAlive: false }, false], [{ servedToken: '', spawnToken: 'mine', childAlive: false }, false] ] + for (const [input, expected] of cases) { - assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input)) + assert.equal(isForeignBackendToken(input as any), expected, JSON.stringify(input)) } }) @@ -128,6 +129,7 @@ test('adoptServedDashboardToken refuses a foreign token when our child is dead', test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => { const logs = [] + const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { childAlive: () => true, fetchText: async () => { diff --git a/apps/desktop/electron/dashboard-token.cjs b/apps/desktop/electron/dashboard-token.ts similarity index 92% rename from apps/desktop/electron/dashboard-token.cjs rename to apps/desktop/electron/dashboard-token.ts index 1a9ca50ad9c2..4f4d3b298d44 100644 --- a/apps/desktop/electron/dashboard-token.cjs +++ b/apps/desktop/electron/dashboard-token.ts @@ -9,29 +9,35 @@ const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000 -async function fetchPublicText(url, options = {}) { +async function fetchPublicText(url, options: any = {}) { const { protocol } = new URL(url) + if (protocol !== 'http:' && protocol !== 'https:') { throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`) } const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS + const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => { if (error.name === 'TimeoutError') { throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`) } + throw error }) + const text = await res.text() - if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`) + if (!res.ok) {throw new Error(`${res.status}: ${text || res.statusText}`)} return text } function extractInjectedDashboardToken(html) { const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || '')) - if (!match) return null + + if (!match) {return null} + try { return JSON.parse(match[1]) } catch { @@ -43,11 +49,13 @@ function dashboardIndexUrl(baseUrl) { return `${String(baseUrl || '').replace(/\/+$/, '')}/` } -async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) { +async function resolveServedDashboardToken(baseUrl, fallbackToken, options: any = {}) { const fetchText = options.fetchText || fetchPublicText + const html = await fetchText(dashboardIndexUrl(baseUrl), { timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS }) + const servedToken = extractInjectedDashboardToken(html) if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') { @@ -76,6 +84,7 @@ function isForeignBackendToken({ servedToken, spawnToken, childAlive }) { async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) { const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => { options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`) + return spawnToken }) @@ -88,12 +97,10 @@ async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, labe return servedToken } -module.exports = { - DEFAULT_TOKEN_FETCH_TIMEOUT_MS, - adoptServedDashboardToken, +export { adoptServedDashboardToken, dashboardIndexUrl, + DEFAULT_TOKEN_FETCH_TIMEOUT_MS, extractInjectedDashboardToken, fetchPublicText, isForeignBackendToken, - resolveServedDashboardToken -} + resolveServedDashboardToken } diff --git a/apps/desktop/electron/desktop-uninstall.test.cjs b/apps/desktop/electron/desktop-uninstall.test.ts similarity index 97% rename from apps/desktop/electron/desktop-uninstall.test.cjs rename to apps/desktop/electron/desktop-uninstall.test.ts index 15a864b7c4f4..3a4cdbbfa457 100644 --- a/apps/desktop/electron/desktop-uninstall.test.cjs +++ b/apps/desktop/electron/desktop-uninstall.test.ts @@ -1,7 +1,7 @@ /** - * Tests for electron/desktop-uninstall.cjs. + * Tests for electron/desktop-uninstall.ts. * - * Run with: node --test electron/desktop-uninstall.test.cjs + * Run with: node --test electron/desktop-uninstall.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * These are the pure helpers behind the desktop Chat GUI uninstaller: the @@ -9,19 +9,17 @@ * cleanup-script builders (POSIX + Windows). */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' +import test from 'node:test' -const { - UNINSTALL_MODES, - buildPosixCleanupScript, +import { buildPosixCleanupScript, buildWindowsCleanupScript, modeRemovesAgent, modeRemovesUserData, resolveRemovableAppPath, shouldRemoveAppBundle, - uninstallArgsForMode -} = require('./desktop-uninstall.cjs') + UNINSTALL_MODES, + uninstallArgsForMode } from './desktop-uninstall' // --- uninstallArgsForMode --- @@ -132,6 +130,7 @@ test('buildPosixCleanupScript waits for the PID, runs the uninstall module, remo appPath: '/opt/hermes/linux-unpacked', hermesHome: '/home/x/.hermes' }) + assert.match(script, /^#!\/bin\/bash/) assert.match(script, /pid=4321/) assert.match(script, /kill -0 "\$pid"/) @@ -152,6 +151,7 @@ test('buildPosixCleanupScript exports PYTHONPATH when pythonPath is set (lite/fu appPath: null, hermesHome: '/home/x/.hermes' }) + // System python + source on PYTHONPATH so import hermes_cli works while the // venv is torn down. assert.match(script, /export PYTHONPATH='\/home\/x\/\.hermes\/hermes-agent'/) @@ -168,6 +168,7 @@ test('buildPosixCleanupScript omits PYTHONPATH when pythonPath is null (gui)', ( appPath: null, hermesHome: '/h' }) + assert.doesNotMatch(script, /export PYTHONPATH/) }) @@ -181,6 +182,7 @@ test('buildPosixCleanupScript omits the bundle rm when appPath is null', () => { appPath: null, hermesHome: '/h' }) + assert.doesNotMatch(script, /rm -rf '\//) // Still runs the uninstall. assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/) @@ -196,6 +198,7 @@ test('buildPosixCleanupScript single-quote-escapes paths with apostrophes', () = appPath: null, hermesHome: '/h' }) + // The apostrophe is closed-escaped-reopened so the shell sees the literal. assert.match(script, /'\/home\/o'\\''brien\/python'/) }) @@ -212,6 +215,7 @@ test('buildWindowsCleanupScript waits (bounded) for PID, runs uninstall, rmdir b appPath: 'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes', hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes' }) + assert.match(script, /@echo off/) assert.match(script, /set "PID=9988"/) // PYTHONPATH set so a system python can import hermes_cli from source. @@ -238,6 +242,7 @@ test('buildWindowsCleanupScript omits PYTHONPATH + rmdir when not needed (gui, n appPath: null, hermesHome: 'C:\\h' }) + assert.doesNotMatch(script, /rmdir/) assert.doesNotMatch(script, /set "PYTHONPATH=/) }) diff --git a/apps/desktop/electron/desktop-uninstall.cjs b/apps/desktop/electron/desktop-uninstall.ts similarity index 93% rename from apps/desktop/electron/desktop-uninstall.cjs rename to apps/desktop/electron/desktop-uninstall.ts index 01b756acd1ed..1254aed2d4dd 100644 --- a/apps/desktop/electron/desktop-uninstall.cjs +++ b/apps/desktop/electron/desktop-uninstall.ts @@ -1,14 +1,14 @@ /** - * desktop-uninstall.cjs + * desktop-uninstall.ts * * Pure, electron-free helpers for the desktop Chat GUI uninstaller. These map * the three user-facing uninstall modes to the `hermes uninstall` CLI flags, * resolve the running app bundle/exe so a detached cleanup script can remove * it after the app quits, and build that cleanup script for each OS. * - * Kept standalone (no `require('electron')`) so it can be unit-tested with - * `node --test` — same pattern as connection-config.cjs / backend-probes.cjs. - * main.cjs requires these and wires them into the electron-coupled IPC layer. + * Kept standalone (no ` import 'electron'`) so it can be unit-tested with + * `node --test` — same pattern as connection-config.ts / backend-probes.ts. + * main.ts requires these and wires them into the electron-coupled IPC layer. * * The three modes mirror the CLI's options exactly: * - 'gui' → remove ONLY the Chat GUI, keep the agent + all user data. @@ -23,10 +23,10 @@ * app bundle (locked on macOS/Windows while the process is alive). So we hand * the work to a detached child that waits for this app's PID to exit, runs the * Python uninstall, then removes the app bundle — then the app quits. Same - * shape as the self-update swap-and-relaunch flow already in main.cjs. + * shape as the self-update swap-and-relaunch flow already in main.ts. */ -const path = require('node:path') +import path from 'node:path' const UNINSTALL_MODES = ['gui', 'lite', 'full'] @@ -41,6 +41,7 @@ function uninstallArgsForMode(mode) { if (!UNINSTALL_MODES.includes(mode)) { throw new Error(`Unknown uninstall mode: ${mode}`) } + return ['-m', 'hermes_cli.uninstall', '--mode', mode] } @@ -65,9 +66,10 @@ function modeRemovesUserData(mode) { * Returns null when we can't confidently identify a removable bundle (e.g. * running from a dev checkout, or a system-package install we must not rmtree). */ -function resolveRemovableAppPath(execPath, platform, env = {}) { +function resolveRemovableAppPath(execPath, platform, env: any = {}) { const exe = String(execPath || '') - if (!exe) return null + + if (!exe) {return null} // Use the path flavor that matches the TARGET platform, not the host running // this code — so the Windows branch parses backslash paths correctly even @@ -79,22 +81,28 @@ function resolveRemovableAppPath(execPath, platform, env = {}) { const macOsDir = p.dirname(exe) // …/Contents/MacOS const contents = p.dirname(macOsDir) // …/Contents const appBundle = p.dirname(contents) // …/Hermes.app - if (appBundle.endsWith('.app')) return appBundle + + if (appBundle.endsWith('.app')) {return appBundle} + return null } if (platform === 'win32') { // NSIS per-user installs Hermes.exe directly in the install dir. const dir = p.dirname(exe) - if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) return dir + + if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) {return dir} + return null } // Linux: an AppImage exposes its own path via the APPIMAGE env var. - if (env.APPIMAGE) return env.APPIMAGE + if (env.APPIMAGE) {return env.APPIMAGE} // Unpacked electron-builder tree: …/linux-unpacked/hermes const dir = p.dirname(exe) - if (/-unpacked$/.test(dir)) return dir + + if (/-unpacked$/.test(dir)) {return dir} + return null } @@ -121,6 +129,7 @@ function shouldRemoveAppBundle(isPackaged, appPath) { */ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) { const q = s => `'${String(s).replace(/'/g, `'\\''`)}'` + const lines = [ '#!/bin/bash', 'set -u', @@ -135,16 +144,21 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, 'fi', `export HERMES_HOME=${q(hermesHome)}` ] + if (pythonPath) { lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`) } + lines.push(`cd ${q(agentRoot)} 2>/dev/null || true`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`) + if (appPath) { lines.push(`rm -rf ${q(appPath)} || true`) } + // Self-delete the script. lines.push('rm -f "$0" 2>/dev/null || true') lines.push('') + return lines.join('\n') } @@ -180,15 +194,18 @@ function buildWindowsCleanupScript({ // under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be // a problem, but Hermes install paths don't use them. const q = s => `"${String(s).replace(/"/g, '')}"` + const lines = [ '@echo off', 'setlocal enableextensions', `set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`, `set "PID=${pid}"` ] + if (pythonPath) { lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`) } + lines.push( 'set /a waited=0', ':waitloop', @@ -206,6 +223,7 @@ function buildWindowsCleanupScript({ `cd /d ${q(agentRoot)}`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}` ) + if (appPath) { lines.push( 'set /a tries=0', @@ -220,18 +238,18 @@ function buildWindowsCleanupScript({ ':rmdone' ) } + lines.push('del "%~f0"') lines.push('') + return lines.join('\r\n') } -module.exports = { - UNINSTALL_MODES, - buildPosixCleanupScript, +export { buildPosixCleanupScript, buildWindowsCleanupScript, modeRemovesAgent, modeRemovesUserData, resolveRemovableAppPath, shouldRemoveAppBundle, - uninstallArgsForMode -} + UNINSTALL_MODES, + uninstallArgsForMode } diff --git a/apps/desktop/electron/embed-referer.cjs b/apps/desktop/electron/embed-referer.ts similarity index 92% rename from apps/desktop/electron/embed-referer.cjs rename to apps/desktop/electron/embed-referer.ts index 2825eda40e70..834fca0cfb24 100644 --- a/apps/desktop/electron/embed-referer.cjs +++ b/apps/desktop/electron/embed-referer.ts @@ -1,9 +1,8 @@ -'use strict' - -const { session } = require('electron') +import { session } from 'electron' const EMBED_SESSION_PARTITION = 'persist:hermes-embed' const EMBED_REFERER = 'https://www.youtube.com/' + const YOUTUBE_REFERER_HOST_RE = /(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i @@ -23,6 +22,7 @@ function installEmbedRefererForSession(embedSession) { if (!YOUTUBE_REFERER_HOST_RE.test(host)) { callback({ requestHeaders: details.requestHeaders }) + return } @@ -45,4 +45,4 @@ function installEmbedReferer() { } } -module.exports = { installEmbedReferer } +export { installEmbedReferer } diff --git a/apps/desktop/electron/fs-read-dir.test.cjs b/apps/desktop/electron/fs-read-dir.test.ts similarity index 97% rename from apps/desktop/electron/fs-read-dir.test.cjs rename to apps/desktop/electron/fs-read-dir.test.ts index 558ec95b5396..67adc624671b 100644 --- a/apps/desktop/electron/fs-read-dir.test.cjs +++ b/apps/desktop/electron/fs-read-dir.test.ts @@ -1,19 +1,17 @@ -'use strict' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { pathToFileURL } from 'node:url' -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') -const { pathToFileURL } = require('node:url') - -const { readDirForIpc } = require('./fs-read-dir.cjs') +import { readDirForIpc } from './fs-read-dir' function mkTmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-')) } -function fakeDirent(name, flags = {}) { +function fakeDirent(name, flags: any = {}) { return { name, isDirectory: () => Boolean(flags.directory), @@ -109,10 +107,12 @@ test('readDirForIpc accepts file URLs for directories', async () => { test('readDirForIpc returns invalid-path for blank or non-string input', async () => { let readdirCalls = 0 + const fsImpl = { promises: { readdir: async () => { readdirCalls += 1 + return [] } } @@ -126,10 +126,12 @@ test('readDirForIpc returns invalid-path for blank or non-string input', async ( test('readDirForIpc rejects Windows device paths before readdir', async () => { let readdirCalls = 0 + const fsImpl = { promises: { readdir: async () => { readdirCalls += 1 + return [] } } @@ -224,6 +226,7 @@ test('readDirForIpc allows expanding symlink or junction directories outside the fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok') const linkPath = path.join(root, 'outside-link') + try { fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir') } catch (error) { @@ -252,6 +255,7 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th const input = path.join('virtual-root') const resolved = path.resolve(input) const statCalls = [] + const fsImpl = { promises: { readdir: async () => [ @@ -266,9 +270,11 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th } statCalls.push(fullPath) + if (fullPath.endsWith(`${path.sep}linked-dir`)) { return { isDirectory: () => true } } + throw Object.assign(new Error('gone'), { code: 'ENOENT' }) } } @@ -301,12 +307,15 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out let peak = 0 let releaseStats let markFirstStatStarted + const statsReleased = new Promise(resolve => { releaseStats = resolve }) + const firstStatStarted = new Promise(resolve => { markFirstStatStarted = resolve }) + const fsImpl = { promises: { readdir: async () => [ @@ -326,6 +335,7 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out active -= 1 const name = path.basename(fullPath) + if (name === failedName) { throw Object.assign(new Error('gone'), { code: 'ENOENT' }) } diff --git a/apps/desktop/electron/fs-read-dir.cjs b/apps/desktop/electron/fs-read-dir.ts similarity index 87% rename from apps/desktop/electron/fs-read-dir.cjs rename to apps/desktop/electron/fs-read-dir.ts index 1a2a00313b56..27b842ea3ffc 100644 --- a/apps/desktop/electron/fs-read-dir.cjs +++ b/apps/desktop/electron/fs-read-dir.ts @@ -1,8 +1,7 @@ -'use strict' +import fs from 'node:fs' +import path from 'node:path' -const fs = require('node:fs') -const path = require('node:path') -const { resolveDirectoryForIpc } = require('./hardening.cjs') +import { resolveDirectoryForIpc } from './hardening' const FS_READDIR_STAT_CONCURRENCY = 16 @@ -37,7 +36,7 @@ function direntIsSymbolicLink(dirent) { } function shouldStatDirent(dirent) { - if (direntIsDirectory(dirent)) return false + if (direntIsDirectory(dirent)) {return false} return direntIsSymbolicLink(dirent) || !direntIsFile(dirent) } @@ -70,13 +69,13 @@ async function mapWithStatConcurrency(items, mapper) { } const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length) - const workers = Array.from({ length: workerCount }, () => runWorker()) + const workers = Array.from({ length: workerCount } as any, () => runWorker()) await Promise.all(workers) return results } -async function readDirForIpc(dirPath, options = {}) { +async function readDirForIpc(dirPath, options: any = {}) { const fsImpl = options.fs || fs let resolved @@ -102,6 +101,4 @@ async function readDirForIpc(dirPath, options = {}) { } } -module.exports = { - readDirForIpc -} +export { readDirForIpc } diff --git a/apps/desktop/electron/gateway-ws-probe.test.cjs b/apps/desktop/electron/gateway-ws-probe.test.ts similarity index 89% rename from apps/desktop/electron/gateway-ws-probe.test.cjs rename to apps/desktop/electron/gateway-ws-probe.test.ts index 810494fdc47b..f1e5ac941283 100644 --- a/apps/desktop/electron/gateway-ws-probe.test.cjs +++ b/apps/desktop/electron/gateway-ws-probe.test.ts @@ -1,7 +1,7 @@ /** - * Tests for electron/gateway-ws-probe.cjs. + * Tests for electron/gateway-ws-probe.ts. * - * Run with: node --test electron/gateway-ws-probe.test.cjs + * Run with: node --test electron/gateway-ws-probe.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * The probe drives a real WebSocket handshake for the "Test remote" button. @@ -9,16 +9,20 @@ * outcome (open, frame, error, early close, never-opens) without a network. */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' +import test from 'node:test' -const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') +import { probeGatewayWebSocket } from './gateway-ws-probe' // Minimal WebSocket double: records listeners synchronously (the probe attaches // them in its executor) and exposes emit() so the test can replay events. -function makeFakeWs() { +function makeFakeWs(): { FakeWs: new (url: string) => any; instances: any[] } { const instances = [] + class FakeWs { + url: string + closed = false + listeners: Record = {} constructor(url) { this.url = url this.listeners = {} @@ -32,9 +36,12 @@ function makeFakeWs() { this.closed = true } emit(type, event) { - for (const fn of this.listeners[type] || []) fn(event) + for (const fn of this.listeners[type] || []) { + fn(event) + } } } + return { FakeWs, instances } } @@ -51,11 +58,13 @@ test('probe resolves ok when the socket opens and stays open', async () => { test('probe resolves ok immediately when a frame arrives', async () => { const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, connectTimeoutMs: 1_000, readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer }) + instances[0].emit('open') instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' }) const result = await promise @@ -95,11 +104,13 @@ test('probe fails when the gateway accepts then immediately closes (auth rejecte test('probe times out when the socket never opens', async () => { const { FakeWs } = makeFakeWs() + const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, connectTimeoutMs: 20, readyGraceMs: 10 }) + assert.equal(result.ok, false) assert.match(result.reason, /Timed out/) }) diff --git a/apps/desktop/electron/gateway-ws-probe.cjs b/apps/desktop/electron/gateway-ws-probe.ts similarity index 87% rename from apps/desktop/electron/gateway-ws-probe.cjs rename to apps/desktop/electron/gateway-ws-probe.ts index 6ed1280be982..7202547e7e8a 100644 --- a/apps/desktop/electron/gateway-ws-probe.cjs +++ b/apps/desktop/electron/gateway-ws-probe.ts @@ -36,13 +36,13 @@ const DEFAULT_READY_GRACE_MS = 750 * Attempt a live WebSocket connection and classify the outcome. * * @param {string} wsUrl - Fully-formed ws(s):// URL including the credential. - * @param {object} [options] - * @param {new (url: string) => any} [options.WebSocketImpl] - WebSocket ctor. - * @param {number} [options.connectTimeoutMs] - * @param {number} [options.readyGraceMs] * @returns {Promise<{ ok: boolean, reason?: string }>} */ -function probeGatewayWebSocket(wsUrl, options = {}) { +function probeGatewayWebSocket(wsUrl: string, options:{ + WebSocketImpl?: any, + connectTimeoutMs?: number + readyGraceMs?: number +} = {}) { const WebSocketImpl = options.WebSocketImpl const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS @@ -54,7 +54,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) { }) } - return new Promise(resolve => { + return new Promise(resolve => { let settled = false let opened = false let connectTimer = null @@ -66,6 +66,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) { clearTimeout(connectTimer) connectTimer = null } + if (graceTimer !== null) { clearTimeout(graceTimer) graceTimer = null @@ -73,14 +74,16 @@ function probeGatewayWebSocket(wsUrl, options = {}) { } const finish = result => { - if (settled) return + if (settled) {return} settled = true clearTimers() + try { socket?.close?.() } catch { // ignore — best effort teardown } + resolve(result) } @@ -91,11 +94,12 @@ function probeGatewayWebSocket(wsUrl, options = {}) { ok: false, reason: error instanceof Error ? error.message : String(error) }) + return } const onOpen = () => { - if (settled) return + if (settled) {return} opened = true // Upgrade accepted. Give the server a brief window to reject the // credential post-handshake (early close) before declaring success. @@ -118,7 +122,8 @@ function probeGatewayWebSocket(wsUrl, options = {}) { } const onClose = event => { - if (settled) return + if (settled) {return} + if (opened) { // Opened, then closed inside the grace window: the upgrade was accepted // but the session was refused (e.g. ws-ticket/token rejected, or a @@ -127,8 +132,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) { ok: false, reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).') }) + return } + finish({ ok: false, reason: closeReason(event, 'The gateway closed the WebSocket before it opened.') @@ -154,8 +161,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) { function addListener(socket, type, handler) { if (typeof socket.addEventListener === 'function') { socket.addEventListener(type, handler) + return } + // Node's global WebSocket implements addEventListener; this fallback keeps the // helper usable with the `ws` package's EventEmitter shape too. if (typeof socket.on === 'function') { @@ -164,25 +173,31 @@ function addListener(socket, type, handler) { } function extractErrorReason(event) { - if (!event) return '' - if (event instanceof Error) return event.message + if (!event) {return ''} + + if (event instanceof Error) {return event.message} const err = event.error || event.message - if (err instanceof Error) return err.message - if (typeof err === 'string') return err + + if (err instanceof Error) {return err.message} + + if (typeof err === 'string') {return err} + return '' } function closeReason(event, fallback) { const code = event && typeof event.code === 'number' ? event.code : null const reason = event && typeof event.reason === 'string' ? event.reason.trim() : '' - if (code && reason) return `${fallback} (code ${code}: ${reason})` - if (code) return `${fallback} (code ${code})` - if (reason) return `${fallback} (${reason})` + + if (code && reason) {return `${fallback} (code ${code}: ${reason})`} + + if (code) {return `${fallback} (code ${code})`} + + if (reason) {return `${fallback} (${reason})`} + return fallback } -module.exports = { - DEFAULT_CONNECT_TIMEOUT_MS, +export { DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READY_GRACE_MS, - probeGatewayWebSocket -} + probeGatewayWebSocket } diff --git a/apps/desktop/electron/git-repo-scan.cjs b/apps/desktop/electron/git-repo-scan.ts similarity index 93% rename from apps/desktop/electron/git-repo-scan.cjs rename to apps/desktop/electron/git-repo-scan.ts index f7617b76b708..36ac189e66e4 100644 --- a/apps/desktop/electron/git-repo-scan.cjs +++ b/apps/desktop/electron/git-repo-scan.ts @@ -1,14 +1,12 @@ -'use strict' - // Repo-first discovery: walk bounded roots for git repos using only Node's `fs` // — no native addon, so it just works for anyone who pulls main (no // electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git` // (don't descend into a repo), cap depth, and skip heavy non-repo trees so the // first scan stays fast. Results are cached by the backend after the first run. -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' const fsp = fs.promises @@ -36,14 +34,14 @@ async function mapLimit(items, limit, fn) { } } - await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) + await Promise.all(Array.from({ length: Math.min(limit, items.length) } as any, worker)) } /** * Scan `roots` (default: the home dir) for git repositories. Returns deduped * `{ root, label }` entries. `options.maxDepth` caps recursion (default 3). */ -async function scanGitRepos(roots, options = {}) { +async function scanGitRepos(roots, options: any = {}) { const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()] const found = new Map() @@ -54,6 +52,7 @@ async function scanGitRepos(roots, options = {}) { } let entries + try { entries = await fsp.readdir(dir, { withFileTypes: true }) } catch { @@ -73,6 +72,7 @@ async function scanGitRepos(roots, options = {}) { } const subdirs = [] + for (const entry of entries) { // Real directories only (skip symlinks to avoid loops), no hidden dirs, no // known heavy trees. @@ -93,4 +93,4 @@ async function scanGitRepos(roots, options = {}) { return [...found.entries()].map(([root, label]) => ({ label, root })) } -module.exports = { scanGitRepos } +export { scanGitRepos } diff --git a/apps/desktop/electron/git-review-ops.test.cjs b/apps/desktop/electron/git-review-ops.test.ts similarity index 78% rename from apps/desktop/electron/git-review-ops.test.cjs rename to apps/desktop/electron/git-review-ops.test.ts index fdddd13df78b..a88de4edf129 100644 --- a/apps/desktop/electron/git-review-ops.test.cjs +++ b/apps/desktop/electron/git-review-ops.test.ts @@ -1,9 +1,7 @@ -'use strict' +import assert from 'node:assert/strict' +import test from 'node:test' -const assert = require('node:assert/strict') -const test = require('node:test') - -const { resolveRenamePath } = require('./git-review-ops.cjs') +import { resolveRenamePath } from './git-review-ops' test('resolveRenamePath: plain path is unchanged', () => { assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts') diff --git a/apps/desktop/electron/git-review-ops.cjs b/apps/desktop/electron/git-review-ops.ts similarity index 93% rename from apps/desktop/electron/git-review-ops.cjs rename to apps/desktop/electron/git-review-ops.ts index a7df19880ebf..cc4f4f3d3c83 100644 --- a/apps/desktop/electron/git-review-ops.cjs +++ b/apps/desktop/electron/git-review-ops.ts @@ -1,37 +1,16 @@ -'use strict' - // Git ops backing the coding rail + Codex-style review pane. Built on `simple-git` // (a maintained wrapper around the system git binary — same git the rest of the // app shells to, no native build) so we read structured status()/diffSummary() // results instead of hand-parsing porcelain. Reads degrade to null/empty on a // non-repo / remote backend; mutations reject so the renderer can toast. -const { execFile } = require('node:child_process') -const fs = require('node:fs/promises') -const path = require('node:path') +import { execFile } from 'node:child_process' +import fs from 'node:fs/promises' +import path from 'node:path' -// `simple-git` is a pure-JS runtime dep that workspace dedup hoists into the -// repo-root node_modules. Packaged builds set `files:` in package.json, which -// excludes node_modules from the asar, so the normal require() fails at launch -// (issue #52735: "Cannot find module 'simple-git'"). We ship the dep's -// closure under resources/native-deps/vendor/node_modules/ via extraResources -// + scripts/stage-native-deps.cjs, and resolve from there when the hoisted -// require() isn't reachable. The `vendor/` nesting matters: electron-builder -// drops a node_modules dir at the root of an extraResources copy but keeps a -// nested one. Dev mode never hits the fallback -- Node's normal lookup finds -// the hoisted copy. -let simpleGit -try { - simpleGit = require('simple-git') -} catch { - const resourcesPath = process.resourcesPath - if (!resourcesPath) { - throw new Error("git-review IPC: 'simple-git' not found and no resourcesPath to fall back to") - } - simpleGit = require(path.join(resourcesPath, 'native-deps', 'vendor', 'node_modules', 'simple-git')) -} +import simpleGit from "simple-git"; -const { resolveRequestedPathForIpc } = require('./hardening.cjs') +import { resolveRequestedPathForIpc } from './hardening' const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000 const COMMIT_CONTEXT_UNTRACKED_MAX = 80 @@ -52,7 +31,7 @@ function ghEnv(ghBin) { // Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on // availability/auth without a throw. gh missing/unauthed → ok:false. -function runGh(args, cwd, ghBin) { +function runGh(args, cwd, ghBin): Promise<{ok: boolean, stdout: string}> { return new Promise(resolve => { execFile( ghBin || 'gh', @@ -260,10 +239,11 @@ async function reviewList(repoPath, scope, baseRef, gitBin) { const range = scope === 'branch' ? `${base}...HEAD` : base const summary = await git.diffSummary([range]) + const files = summary.files.map(file => ({ path: resolveRenamePath(file.file), - added: file.binary ? 0 : file.insertions, - removed: file.binary ? 0 : file.deletions, + added: 'insertions' in file ? file.insertions : 0 , + removed: 'deletions' in file ? file.deletions : 0 , status: 'M', staged: false })) @@ -291,6 +271,7 @@ async function reviewList(repoPath, scope, baseRef, gitBin) { git.diffSummary(['--cached']), git.diffSummary([]) ]) + const stagedCounts = countsByPath(staged) const unstagedCounts = countsByPath(unstaged) @@ -495,6 +476,7 @@ async function reviewCommitContext(repoPath, gitBin) { const safe = args => git.diff(args).catch(() => '') let status + try { status = await git.status() } catch { @@ -510,9 +492,11 @@ async function reviewCommitContext(repoPath, gitBin) { // Untracked files have no diff — list them so new files aren't invisible. const untracked = status.not_added || [] + if (untracked.length > 0) { const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX) const omitted = untracked.length - visible.length + const note = `\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` + (omitted > 0 ? `# ... ${omitted} more omitted\n` : '') @@ -607,6 +591,7 @@ async function repoStatus(repoPath, gitBin) { // fail soft and hide the coding rail instead of spamming IPC handler errors. try { const stat = await fs.stat(cwd) + if (!stat.isDirectory()) { return null } @@ -615,11 +600,13 @@ async function repoStatus(repoPath, gitBin) { } let git + try { git = gitFor(cwd, gitBin) } catch { return null } + let status try { @@ -630,6 +617,7 @@ async function repoStatus(repoPath, gitBin) { } const detached = typeof status.detached === 'boolean' ? status.detached : !status.current + const files = status.files.map(file => ({ path: file.path, staged: isStaged(file), @@ -671,10 +659,12 @@ async function repoStatus(repoPath, gitBin) { // can't stall the probe. try { const untracked = status.not_added.slice(0, 500) + for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) { const batch = await Promise.all( untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path)) ) + result.added += batch.reduce((sum, n) => sum + n, 0) } } catch { @@ -684,8 +674,7 @@ async function repoStatus(repoPath, gitBin) { return result } -module.exports = { - branchBase, +export { branchBase, fileDiffVsHead, repoStatus, resolveRenamePath, @@ -695,9 +684,8 @@ module.exports = { reviewDiff, reviewList, reviewPush, - reviewRevParse, reviewRevert, + reviewRevParse, reviewShipInfo, reviewStage, - reviewUnstage -} + reviewUnstage } diff --git a/apps/desktop/electron/git-root.test.cjs b/apps/desktop/electron/git-root.test.ts similarity index 80% rename from apps/desktop/electron/git-root.test.cjs rename to apps/desktop/electron/git-root.test.ts index ba649b259f3c..cda56d51a49a 100644 --- a/apps/desktop/electron/git-root.test.cjs +++ b/apps/desktop/electron/git-root.test.ts @@ -1,13 +1,11 @@ -'use strict' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { pathToFileURL } from 'node:url' -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') -const { pathToFileURL } = require('node:url') - -const { gitRootForIpc } = require('./git-root.cjs') +import { gitRootForIpc } from './git-root' function mkTmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-')) diff --git a/apps/desktop/electron/git-root.cjs b/apps/desktop/electron/git-root.ts similarity index 75% rename from apps/desktop/electron/git-root.cjs rename to apps/desktop/electron/git-root.ts index 593d3531ebce..93ea25988025 100644 --- a/apps/desktop/electron/git-root.cjs +++ b/apps/desktop/electron/git-root.ts @@ -1,8 +1,7 @@ -'use strict' +import fs from 'node:fs' +import path from 'node:path' -const fs = require('node:fs') -const path = require('node:path') -const { resolveRequestedPathForIpc } = require('./hardening.cjs') +import { resolveRequestedPathForIpc } from './hardening' function findGitRoot(start, fsImpl = fs) { let dir = start @@ -28,7 +27,7 @@ function findGitRoot(start, fsImpl = fs) { return null } -async function gitRootForIpc(startPath, options = {}) { +async function gitRootForIpc(startPath, options: {fs?: typeof fs} = {}) { const fsImpl = options.fs || fs let resolved @@ -48,7 +47,5 @@ async function gitRootForIpc(startPath, options = {}) { } } -module.exports = { - findGitRoot, - gitRootForIpc -} +export { findGitRoot, + gitRootForIpc } diff --git a/apps/desktop/electron/git-worktree-ops.test.cjs b/apps/desktop/electron/git-worktree-ops.test.ts similarity index 95% rename from apps/desktop/electron/git-worktree-ops.test.cjs rename to apps/desktop/electron/git-worktree-ops.test.ts index b0865d4ad777..65e59c2d29d7 100644 --- a/apps/desktop/electron/git-worktree-ops.test.cjs +++ b/apps/desktop/electron/git-worktree-ops.test.ts @@ -1,20 +1,16 @@ -'use strict' +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' -const assert = require('node:assert/strict') -const { execFileSync } = require('node:child_process') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') - -const { - addWorktree, +import { addWorktree, ensureGitRepo, listBranches, parseWorktrees, sanitizeBranch, - switchBranch -} = require('./git-worktree-ops.cjs') + switchBranch } from './git-worktree-ops' test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => { assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes') diff --git a/apps/desktop/electron/git-worktree-ops.cjs b/apps/desktop/electron/git-worktree-ops.ts similarity index 96% rename from apps/desktop/electron/git-worktree-ops.cjs rename to apps/desktop/electron/git-worktree-ops.ts index de4e01cfb94c..93efaf13c4eb 100644 --- a/apps/desktop/electron/git-worktree-ops.cjs +++ b/apps/desktop/electron/git-worktree-ops.ts @@ -1,16 +1,14 @@ -'use strict' - // Git-driven worktree operations for the desktop "Start work" flow: spin up a // fresh worktree the lightest way (`git worktree add -b`), list real worktrees, // and remove them. Git is the source of truth; the renderer just drives these. -const path = require('node:path') -const fs = require('node:fs') -const { execFile } = require('node:child_process') +import { execFile } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' -const { resolveRequestedPathForIpc } = require('./hardening.cjs') +import { resolveRequestedPathForIpc } from './hardening' -function runGit(gitBin, args, cwd) { +function runGit(gitBin, args, cwd): Promise { return new Promise((resolve, reject) => { execFile( gitBin, @@ -306,6 +304,7 @@ async function listBranches(repoPath, gitBin) { ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'], resolved ) + const trees = await listWorktrees(resolved, gitBin) const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path])) const trunk = await defaultBranch(gitBin, resolved) @@ -338,13 +337,11 @@ async function switchBranch(repoPath, branch, gitBin) { return { branch: target } } -module.exports = { - addWorktree, +export { addWorktree, ensureGitRepo, listBranches, listWorktrees, parseWorktrees, removeWorktree, sanitizeBranch, - switchBranch -} + switchBranch } diff --git a/apps/desktop/electron/hardening.test.cjs b/apps/desktop/electron/hardening.test.ts similarity index 95% rename from apps/desktop/electron/hardening.test.cjs rename to apps/desktop/electron/hardening.test.ts index b38a03b00828..324e511ac3e9 100644 --- a/apps/desktop/electron/hardening.test.cjs +++ b/apps/desktop/electron/hardening.test.ts @@ -1,23 +1,22 @@ -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') -const { pathToFileURL } = require('node:url') +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { pathToFileURL } from 'node:url' -const { - DEFAULT_FETCH_TIMEOUT_MS, +import { DEFAULT_FETCH_TIMEOUT_MS, encryptDesktopSecret, resolveDirectoryForIpc, resolveReadableFileForIpc, resolveRequestedPathForIpc, resolveTimeoutMs, - sensitiveFileBlockReason -} = require('./hardening.cjs') + sensitiveFileBlockReason } from './hardening' -async function rejectsWithCode(promise, code) { - await assert.rejects(promise, error => { +async function rejectsWithCode(promise, code: string) { + await assert.rejects(promise, (error: any) => { assert.equal(error?.code, code) + return true }) } @@ -76,8 +75,9 @@ test('path helpers reject blank non-string NUL and Windows device syntax', async for (const devicePath of devicePaths) { assert.throws( () => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }), - error => { + (error: any) => { assert.equal(error?.code, 'device-path') + return true } ) @@ -86,8 +86,9 @@ test('path helpers reject blank non-string NUL and Windows device syntax', async assert.throws( () => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), - error => { + (error: any) => { assert.equal(error?.code, 'invalid-path') + return true } ) @@ -131,19 +132,23 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity', maxBytes: 256, purpose: 'File preview' }) + assert.equal(fromRelative.resolvedPath, textPath) assert.equal(fromRelative.stat.size, 11) const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), { purpose: 'File preview' }) + assert.equal(fromFileUrl.resolvedPath, textPath) const spacedPath = path.join(tempDir, 'notes with spaces.txt') fs.writeFileSync(spacedPath, 'space ok', 'utf8') + const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), { purpose: 'File preview' }) + assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath) await assert.rejects( @@ -184,9 +189,11 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity', const envTemplatePath = path.join(tempDir, '.env.example') fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8') + const envTemplate = await resolveReadableFileForIpc(envTemplatePath, { purpose: 'File preview' }) + assert.equal(envTemplate.resolvedPath, envTemplatePath) }) @@ -229,8 +236,10 @@ test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', as } catch (error) { if (error?.code === 'EPERM' || error?.code === 'EACCES') { t.skip(`symlink creation is not permitted on this platform (${error.code})`) + return } + throw error } @@ -268,8 +277,10 @@ test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t = } catch (error) { if (error?.code === 'EPERM' || error?.code === 'EACCES') { t.skip(`directory symlink creation is not permitted on this platform (${error.code})`) + return } + throw error } diff --git a/apps/desktop/electron/hardening.cjs b/apps/desktop/electron/hardening.ts similarity index 89% rename from apps/desktop/electron/hardening.cjs rename to apps/desktop/electron/hardening.ts index 574e659f96c3..b344f5960400 100644 --- a/apps/desktop/electron/hardening.cjs +++ b/apps/desktop/electron/hardening.ts @@ -1,7 +1,7 @@ -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { fileURLToPath } = require('node:url') +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' const DEFAULT_FETCH_TIMEOUT_MS = 15_000 const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024 @@ -13,6 +13,7 @@ const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx']) function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) { const fallback = Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS + const parsed = Number(timeoutMs) if (Number.isFinite(parsed) && parsed > 0) { @@ -62,6 +63,7 @@ function sensitiveFileBlockReason(filePath) { const normalized = String(filePath || '') .replace(/\\/g, '/') .toLowerCase() + const basename = path.basename(normalized) const ext = path.extname(basename) @@ -87,6 +89,7 @@ function sensitiveFileBlockReason(filePath) { if (basename.startsWith('.env.')) { const suffix = basename.slice('.env.'.length) + if (!SAFE_ENV_SUFFIXES.has(suffix)) { return `${basename} is blocked because it appears to contain environment secrets.` } @@ -107,9 +110,10 @@ function sensitiveFileBlockReason(filePath) { return null } -function ipcPathError(code, message) { - const error = new Error(message) - error.code = code +function ipcPathError(code: any, message: string): Error & {code: any} { + const error = new Error(message) as Error & {code: any} + (error as any).code = code + return error } @@ -129,6 +133,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') { } const normalized = raw.replace(/\\/g, '/').toLowerCase() + if ( normalized.startsWith('//?/') || normalized.startsWith('//./') || @@ -141,7 +146,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') { return raw } -function resolveRequestedPathForIpc(filePath, options = {}) { +function resolveRequestedPathForIpc(filePath, options: {purpose?: string, baseDir?: fs.PathOrFileDescriptor} = {}) { const purpose = String(options.purpose || 'File read') let raw = rejectUnsafePathSyntax(filePath, purpose) @@ -154,17 +159,21 @@ function resolveRequestedPathForIpc(filePath, options = {}) { if (/^file:/i.test(raw)) { let resolvedPath + try { const parsed = new URL(raw) + if (parsed.protocol !== 'file:') { throw new Error('not a file URL') } + resolvedPath = fileURLToPath(parsed) } catch { throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`) } rejectUnsafePathSyntax(resolvedPath, purpose) + return path.resolve(resolvedPath) } @@ -178,14 +187,16 @@ function resolveRequestedPathForIpc(filePath, options = {}) { return resolvedPath } -async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) { +async function statForIpc(fsImpl: {promises: {stat: typeof fs.promises.stat}}, resolvedPath, purpose, typeLabel) { try { return await fsImpl.promises.stat(resolvedPath) } catch (error) { const code = error && typeof error === 'object' ? error.code : '' + if (code === 'ENOENT' || code === 'ENOTDIR') { throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`) } + throw ipcPathError( code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}` @@ -201,6 +212,7 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) { try { const realPath = await fsImpl.promises.realpath(resolvedPath) rejectUnsafePathSyntax(realPath, purpose) + return realPath } catch (error) { const code = error && typeof error === 'object' ? error.code : '' @@ -213,12 +225,13 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) { function rejectSensitiveFilePath(filePath, purpose) { const blockReason = sensitiveFileBlockReason(filePath) + if (blockReason) { throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`) } } -async function resolveDirectoryForIpc(dirPath, options = {}) { +async function resolveDirectoryForIpc(dirPath, options: {purpose?: string , baseDir?: fs.PathOrFileDescriptor, fs?: {promises:{stat: typeof fs.promises.stat}}} = {}) { const purpose = String(options.purpose || 'Directory read') const fsImpl = options.fs || fs const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose }) @@ -233,7 +246,7 @@ async function resolveDirectoryForIpc(dirPath, options = {}) { return { realPath, resolvedPath, stat } } -async function resolveReadableFileForIpc(filePath, options = {}) { +async function resolveReadableFileForIpc(filePath, options: {purpose?: string , baseDir?: fs.PathOrFileDescriptor, fs?: typeof fs, blockSensitive?: boolean, maxBytes?: number} = {}) { const purpose = String(options.purpose || 'File read') const fsImpl = options.fs || fs const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose }) @@ -253,11 +266,13 @@ async function resolveReadableFileForIpc(filePath, options = {}) { } const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose) + if (options.blockSensitive !== false) { rejectSensitiveFilePath(realPath, purpose) } const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null + if (maxBytes && stat.size > maxBytes) { throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`) } @@ -271,15 +286,13 @@ async function resolveReadableFileForIpc(filePath, options = {}) { return { realPath, resolvedPath, stat } } -module.exports = { - DATA_URL_READ_MAX_BYTES, +export { DATA_URL_READ_MAX_BYTES, DEFAULT_FETCH_TIMEOUT_MS, - TEXT_PREVIEW_SOURCE_MAX_BYTES, encryptDesktopSecret, rejectUnsafePathSyntax, resolveDirectoryForIpc, resolveReadableFileForIpc, resolveRequestedPathForIpc, resolveTimeoutMs, - sensitiveFileBlockReason -} + sensitiveFileBlockReason, + TEXT_PREVIEW_SOURCE_MAX_BYTES } diff --git a/apps/desktop/electron/link-title-window.test.cjs b/apps/desktop/electron/link-title-window.test.ts similarity index 95% rename from apps/desktop/electron/link-title-window.test.cjs rename to apps/desktop/electron/link-title-window.test.ts index 468c646a047e..e1b1cc5794f0 100644 --- a/apps/desktop/electron/link-title-window.test.cjs +++ b/apps/desktop/electron/link-title-window.test.ts @@ -1,15 +1,14 @@ -const assert = require('node:assert/strict') -const test = require('node:test') +import assert from 'node:assert/strict' +import test from 'node:test' -const { - createLinkTitleWindow, +import { createLinkTitleWindow, guardLinkTitleSession, linkTitleWindowOptions, - readLinkTitleWindowTitle -} = require('./link-title-window.cjs') + readLinkTitleWindowTitle } from './link-title-window' function makeFakeBrowserWindow() { const calls = { audioMuted: [] } + const FakeBrowserWindow = function (options) { this.options = options this.webContents = { diff --git a/apps/desktop/electron/link-title-window.cjs b/apps/desktop/electron/link-title-window.ts similarity index 80% rename from apps/desktop/electron/link-title-window.cjs rename to apps/desktop/electron/link-title-window.ts index c6792bf989e1..9e67e4b14157 100644 --- a/apps/desktop/electron/link-title-window.cjs +++ b/apps/desktop/electron/link-title-window.ts @@ -1,11 +1,9 @@ -'use strict' - // Hidden BrowserWindow used by tier-2 link-title resolution: when curl can't // read a page (bot walls, JS-rendered pages), we briefly load the URL // in an offscreen window and read its title. That window loads arbitrary // user-linked pages, so it must never emit sound or trigger real downloads. -function linkTitleWindowOptions(partitionSession) { +export function linkTitleWindowOptions(partitionSession) { return { show: false, width: 1280, @@ -25,7 +23,7 @@ function linkTitleWindowOptions(partitionSession) { // Create the offscreen title-fetch window and immediately mute it. Without the // mute, autoplaying media on the loaded page (e.g. a YouTube link) leaks ~2s of // audio every time a session containing such links is re-rendered. See #49505. -function createLinkTitleWindow(BrowserWindow, partitionSession) { +export function createLinkTitleWindow(BrowserWindow, partitionSession) { const window = new BrowserWindow(linkTitleWindowOptions(partitionSession)) try { @@ -41,7 +39,7 @@ function createLinkTitleWindow(BrowserWindow, partitionSession) { // Cancel any download the title-fetch window triggers. Without this, a link // artifact URL served with Content-Disposition: attachment auto-downloads every // time the Artifacts page renders and fetchLinkTitle loads it. -function guardLinkTitleSession(partitionSession) { +export function guardLinkTitleSession(partitionSession) { try { partitionSession.on('will-download', (_event, item) => item.cancel()) } catch { @@ -52,20 +50,15 @@ function guardLinkTitleSession(partitionSession) { // Read the page title from a title-fetch window. Callers schedule this from // timers that can fire after finish() destroys the window, so every access must // guard isDestroyed and swallow Electron's "Object has been destroyed" throws. -function readLinkTitleWindowTitle(window) { +export function readLinkTitleWindowTitle(window) { try { - if (!window || window.isDestroyed()) return '' + if (!window || window.isDestroyed()) {return ''} const contents = window.webContents - if (!contents || contents.isDestroyed()) return '' + + if (!contents || contents.isDestroyed()) {return ''} + return contents.getTitle() || '' } catch { return '' } } - -module.exports = { - createLinkTitleWindow, - guardLinkTitleSession, - linkTitleWindowOptions, - readLinkTitleWindowTitle -} diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.ts similarity index 94% rename from apps/desktop/electron/main.cjs rename to apps/desktop/electron/main.ts index 69d5d5f715bf..93f2893d6766 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.ts @@ -1,74 +1,65 @@ -const { - app, +type Method = string +import type { ExecFileSyncOptionsWithStringEncoding} from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process' +import crypto from 'node:crypto' +import fs from 'node:fs' +import http from 'node:http' +import https from 'node:https' +import os from "node:os" +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +import { app, BrowserWindow, - Menu, - Notification, clipboard, dialog, + net as electronNet, ipcMain, + Menu, nativeImage, nativeTheme, - net: electronNet, + Notification, powerMonitor, protocol, safeStorage, screen, session, shell, - systemPreferences -} = require('electron') -const crypto = require('node:crypto') -const fs = require('node:fs') -const http = require('node:http') -const https = require('node:https') -const os = require('node:os') -const path = require('node:path') -const { pathToFileURL } = require('node:url') -const { execFileSync, spawn } = require('node:child_process') -const { installEmbedReferer } = require('./embed-referer.cjs') -const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') -const { runBootstrap } = require('./bootstrap-runner.cjs') -const { - buildSessionWindowUrl, - chatWindowWebPreferences, - createSessionWindowRegistry, - SESSION_WINDOW_MIN_HEIGHT, - SESSION_WINDOW_MIN_WIDTH -} = require('./session-windows.cjs') -const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') -const { - createLinkTitleWindow, - guardLinkTitleSession, - readLinkTitleWindowTitle -} = require('./link-title-window.cjs') -const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') -const { adoptServedDashboardToken } = require('./dashboard-token.cjs') -const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs') -const { dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs') -const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') -const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') -const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs') -const { readWindowsUserEnvVar } = require('./windows-user-env.cjs') -const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs') -const { - nativeOverlayWidth: computeNativeOverlayWidth, - macTitleBarOverlayHeight -} = require('./titlebar-overlay-width.cjs') -const { readDirForIpc } = require('./fs-read-dir.cjs') -const { readLiveUpdateMarker, writeUpdateMarker } = require('./update-marker.cjs') -const { - resolveUnpackedRelease, - decideRelaunchOutcome, - sandboxPreflight, - sandboxFallbackFromEnv, - collectRelaunchArgs, - collectRelaunchEnv, - buildRelaunchScript -} = require('./update-relaunch.cjs') -const { gitRootForIpc } = require('./git-root.cjs') -const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs') -const { - fileDiffVsHead, + systemPreferences } from 'electron' +import nodePty from "node-pty" + +import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'; +import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env' +import { canImportHermesCli, verifyHermesCli } from './backend-probes' +import { waitForDashboardPortAnnouncement } from './backend-ready' +import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform' +import { runBootstrap } from './bootstrap-runner' +import { authModeFromStatus, + buildGatewayWsUrl, + buildGatewayWsUrlWithTicket, + connectionScopeKey, + cookiesHaveLiveSession, + cookiesHaveSession, + normalizeRemoteBaseUrl, + normAuthMode, + pathWithGlobalRemoteProfile, + profileRemoteOverride, + resolveAuthMode, + resolveTestWsUrl, + tokenPreview } from './connection-config' +import { adoptServedDashboardToken } from './dashboard-token' +import { buildPosixCleanupScript, + buildWindowsCleanupScript, + modeRemovesAgent, + modeRemovesUserData, + resolveRemovableAppPath, + shouldRemoveAppBundle, + uninstallArgsForMode } from './desktop-uninstall' +import { installEmbedReferer } from './embed-referer' +import { readDirForIpc } from './fs-read-dir' +import { probeGatewayWebSocket } from './gateway-ws-probe' +import { scanGitRepos } from './git-repo-scan' +import { fileDiffVsHead, repoStatus, reviewCommit, reviewCommitContext, @@ -76,87 +67,51 @@ const { reviewDiff, reviewList, reviewPush, - reviewRevParse, reviewRevert, + reviewRevParse, reviewShipInfo, reviewStage, - reviewUnstage -} = require('./git-review-ops.cjs') -const { scanGitRepos } = require('./git-repo-scan.cjs') -const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs') -const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs') -const { runRebuildWithRetry } = require('./update-rebuild.cjs') -const { - buildPosixCleanupScript, - buildWindowsCleanupScript, - modeRemovesAgent, - modeRemovesUserData, - resolveRemovableAppPath, - shouldRemoveAppBundle, - uninstallArgsForMode -} = require('./desktop-uninstall.cjs') -const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./workspace-cwd.cjs') -const { - MIN_WIDTH: WINDOW_MIN_WIDTH, - MIN_HEIGHT: WINDOW_MIN_HEIGHT, - sanitizeWindowState, - computeWindowOptions, - debounce -} = require('./window-state.cjs') -const { - authModeFromStatus, - buildGatewayWsUrl, - buildGatewayWsUrlWithTicket, - connectionScopeKey, - cookiesHaveSession, - cookiesHaveLiveSession, - normAuthMode, - normalizeRemoteBaseUrl, - pathWithGlobalRemoteProfile, - profileRemoteOverride, - resolveAuthMode, - resolveTestWsUrl, - tokenPreview -} = require('./connection-config.cjs') -const { - DATA_URL_READ_MAX_BYTES, + reviewUnstage } from './git-review-ops' +import { gitRootForIpc } from './git-root' +import { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops' +import { DATA_URL_READ_MAX_BYTES, DEFAULT_FETCH_TIMEOUT_MS, - TEXT_PREVIEW_SOURCE_MAX_BYTES, - encryptDesktopSecret: encryptDesktopSecretStrict, + encryptDesktopSecret as encryptDesktopSecretStrict, resolveReadableFileForIpc, resolveRequestedPathForIpc, - resolveTimeoutMs -} = require('./hardening.cjs') - -let nodePty = null -let nodePtyDir = null - -try { - nodePty = require('node-pty') - nodePtyDir = path.dirname(require.resolve('node-pty/package.json')) -} catch { - // Packaged builds set `files:` in package.json, which excludes node_modules - // from the asar. Workspace dedup also hoists this native dep to the repo - // root's node_modules, out of reach of electron-builder's collector. We - // ship a minimal copy under resources/native-deps/ via extraResources + - // scripts/stage-native-deps.cjs; resolve from there when the normal - // require() fails. Dev mode never reaches this branch -- the hoisted - // resolve succeeds via Node's normal module lookup. - try { - const path = require('node:path') - const resourcesPath = process.resourcesPath - if (resourcesPath) { - nodePtyDir = path.join(resourcesPath, 'native-deps', 'node-pty') - nodePty = require(nodePtyDir) - } - } catch { - console.log(`[terminal] failed to load node-pty from path ${nodePtyDir}`) - nodePty = null - nodePtyDir = null - } -} + resolveTimeoutMs, + TEXT_PREVIEW_SOURCE_MAX_BYTES } from './hardening' +import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' +import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' +import { buildSessionWindowUrl, + chatWindowWebPreferences, + createSessionWindowRegistry, + SESSION_WINDOW_MIN_HEIGHT, + SESSION_WINDOW_MIN_WIDTH } from './session-windows' +import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' +import { resolveBehindCount, shouldCountCommits } from './update-count' +import { readLiveUpdateMarker, writeUpdateMarker } from './update-marker' +import { runRebuildWithRetry } from './update-rebuild' +import { buildRelaunchScript, + collectRelaunchArgs, + collectRelaunchEnv, + decideRelaunchOutcome, + resolveUnpackedRelease, + sandboxFallbackFromEnv, + sandboxPreflight } from './update-relaunch' +import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' +import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace' +import { computeWindowOptions, + debounce, + sanitizeWindowState, + MIN_HEIGHT as WINDOW_MIN_HEIGHT, + MIN_WIDTH as WINDOW_MIN_WIDTH } from './window-state' +import { readWindowsUserEnvVar } from './windows-user-env' +import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd' +import { readWslWindowsClipboardImage } from './wsl-clipboard-image' const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR + if (USER_DATA_OVERRIDE) { const resolvedUserData = path.resolve(USER_DATA_OVERRIDE) fs.mkdirSync(resolvedUserData, { recursive: true }) @@ -164,7 +119,7 @@ if (USER_DATA_OVERRIDE) { } const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER -const IS_PACKAGED = app.isPackaged +const IS_PACKAGED = app.isPackaged || Boolean(process.env.HERMES_DESKTOP_IS_PACKAGED) const IS_MAC = process.platform === 'darwin' const IS_WINDOWS = process.platform === 'win32' const IS_WSL = isWslEnvironment() @@ -173,11 +128,18 @@ const IS_WSL = isWslEnvironment() const DARWIN_MAJOR = IS_MAC ? Number.parseInt(os.release(), 10) || 0 : 0 const APP_ROOT = app.getAppPath() -function hiddenWindowsChildOptions(options = {}) { +// Preload script path: in dev we load the .ts source directly (tsx handles +// the transform); in prod we load the bundled .js from dist/. +const PRELOAD_PATH = IS_PACKAGED + ? path.join(APP_ROOT, 'dist', 'electron-preload.js') + : path.join(import.meta.dirname, 'preload.ts') + +function hiddenWindowsChildOptions(options: any = {}): ExecFileSyncOptionsWithStringEncoding { if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) { - return options + return options as any } - return { ...options, windowsHide: true } + + return { ...options, windowsHide: true } as any } // Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU @@ -190,6 +152,7 @@ function hiddenWindowsChildOptions(options = {}) { // switches only apply pre-launch. Override with HERMES_DESKTOP_DISABLE_GPU // (1/true → always disable, 0/false → keep GPU on). const REMOTE_DISPLAY_REASON = detectRemoteDisplay() + if (REMOTE_DISPLAY_REASON) { app.disableHardwareAcceleration() // Belt-and-suspenders for X11/VNC, where the Viz compositor can still glitch @@ -229,7 +192,7 @@ const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') // Build-time install stamp -- the git ref this .exe was built against. // -// Written by apps/desktop/scripts/write-build-stamp.cjs during `npm run build` +// Written by apps/desktop/scripts/write-build-stamp.mjs during `npm run build` // and bundled into packaged apps via electron-builder's extraResources entry, // so the runtime stamp ends up at process.resourcesPath/install-stamp.json // after install. The bootstrap runner (Phase 1D) reads it to know which @@ -241,6 +204,7 @@ const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') // Schema: // { schemaVersion: 1, commit, branch, builtAt, dirty, source } const INSTALL_STAMP_SCHEMA_VERSION = 1 + function loadInstallStamp() { // Try packaged location first (resources/install-stamp.json), then the // dev/local build output (apps/desktop/build/install-stamp.json) so @@ -248,19 +212,24 @@ function loadInstallStamp() { // sees a stamp without needing a packaged build. const candidates = [ process.resourcesPath ? path.join(process.resourcesPath, 'install-stamp.json') : null, - path.join(APP_ROOT, 'build', 'install-stamp.json') + path.join(APP_ROOT, 'build', 'install-stamp.json'), ].filter(Boolean) + + for (const p of candidates) { try { const raw = fs.readFileSync(p, 'utf8') const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && typeof parsed.commit === 'string' && parsed.commit.length >= 7) { if (parsed.schemaVersion !== INSTALL_STAMP_SCHEMA_VERSION) { console.warn( `[hermes] install-stamp.json schemaVersion ${parsed.schemaVersion} != expected ${INSTALL_STAMP_SCHEMA_VERSION}; ignoring` ) + continue } + return Object.freeze({ schemaVersion: parsed.schemaVersion, commit: parsed.commit, @@ -271,13 +240,17 @@ function loadInstallStamp() { path: p }) } - } catch { + } catch (e) { + console.warn(`[hermes] install-stamp.json found at ${p} , but parsing failed with ${e}`) // Either ENOENT or malformed JSON; try the next candidate } } + return null } + const INSTALL_STAMP = loadInstallStamp() + if (INSTALL_STAMP) { console.log( `[hermes] install stamp: ${INSTALL_STAMP.commit.slice(0, 12)}${INSTALL_STAMP.branch ? ` (${INSTALL_STAMP.branch})` : ''}${INSTALL_STAMP.dirty ? ' [DIRTY]' : ''} from ${INSTALL_STAMP.source || 'unknown'}` @@ -306,8 +279,10 @@ if (INSTALL_STAMP) { // HERMES_HOME beneath the throwaway userData dir so a fresh-install run never // touches the user's real ~/.hermes / %LOCALAPPDATA%\hermes. function resolveHermesHome() { - if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME) - if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home') + if (process.env.HERMES_HOME) {return normalizeHermesHomeRoot(process.env.HERMES_HOME)} + + if (USER_DATA_OVERRIDE) {return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home')} + if (IS_WINDOWS) { // A GUI app launched from Explorer inherits the environment block captured // at login, so a HERMES_HOME set via `setx` AFTER login is invisible in @@ -316,16 +291,21 @@ function resolveHermesHome() { // inference provider configured" despite a valid configured home (#45471). // Consult the live User-scoped registry value before the default below. const fromRegistry = readWindowsUserEnvVar('HERMES_HOME') - if (fromRegistry) return normalizeHermesHomeRoot(fromRegistry) + + if (fromRegistry) {return normalizeHermesHomeRoot(fromRegistry)} } + if (IS_WINDOWS && process.env.LOCALAPPDATA) { const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes') const legacy = path.join(app.getPath('home'), '.hermes') + // Migrate transparently to LOCALAPPDATA, but honour an existing legacy // ~/.hermes setup (no LOCALAPPDATA install yet) so users don't lose state. - if (!directoryExists(localappdata) && directoryExists(legacy)) return legacy + if (!directoryExists(localappdata) && directoryExists(legacy)) {return legacy} + return localappdata } + return path.join(app.getPath('home'), '.hermes') } @@ -338,6 +318,7 @@ function hermesManagedNodePathEntries() { const root = path.join(HERMES_HOME, 'node') const bin = path.join(root, 'bin') const entries = IS_WINDOWS ? [root, bin] : [bin, root] + return entries.filter(directoryExists) } @@ -408,19 +389,25 @@ const DESKTOP_LOG_BACKUP_COUNT = 3 const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4 const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}` const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1' + const BOOT_FAKE_STEP_MS = (() => { const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10) - if (!Number.isFinite(raw) || raw <= 0) return 650 + + if (!Number.isFinite(raw) || raw <= 0) {return 650} + return Math.max(120, raw) })() + const APP_NAME = 'Hermes' const TITLEBAR_HEIGHT = 34 const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14 + const WINDOW_BUTTON_POSITION = { x: 24, y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2 } -// Right-edge window-control reservation lives in titlebar-overlay-width.cjs + +// Right-edge window-control reservation lives in titlebar-overlay-width.ts // (pure + unit-testable); computeNativeOverlayWidth() applies it per platform. // It's only the pre-layout fallback — the renderer measures the exact overlay // width live via the Window Controls Overlay API. @@ -574,6 +561,7 @@ function getTitleBarOverlayOptions() { // setTitleBarOverlay isn't supported. function applyTitleBarOverlay(win) { const options = getTitleBarOverlayOptions() + if (!options || typeof options !== 'object') { return } @@ -611,6 +599,7 @@ const PREVIEW_HTML_EXTENSIONS = new Set(['.html', '.htm']) const PREVIEW_WATCH_DEBOUNCE_MS = 120 const LOCAL_PREVIEW_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost']) const TEXT_PREVIEW_MAX_BYTES = 512 * 1024 + const PREVIEW_LANGUAGE_BY_EXT = { '.c': 'c', '.conf': 'ini', @@ -647,14 +636,15 @@ const PREVIEW_LANGUAGE_BY_EXT = { } function looksBinary(buffer) { - if (!buffer.length) return false + if (!buffer.length) {return false} let suspicious = 0 for (const byte of buffer) { - if (byte === 0) return true + if (byte === 0) {return true} + // Allow common whitespace controls: tab, LF, CR. - if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) suspicious += 1 + if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {suspicious += 1} } return suspicious / buffer.length > 0.12 @@ -691,6 +681,7 @@ function previewFileMetadata(filePath, mimeType) { } app.setName(APP_NAME) + // Windows toast notifications silently no-op unless an AppUserModelID is set: // `new Notification().show()` returns without error and nothing appears. The // AUMID must match the installed Start Menu shortcut's AUMID, which @@ -701,6 +692,7 @@ app.setName(APP_NAME) if (IS_WINDOWS) { app.setAppUserModelId('com.nousresearch.hermes') } + // Seed the native About panel with the live Hermes version. This is refreshed // on every open via the explicit "About" menu handler (refreshAboutPanel), so // an in-place `hermes update` mid-session is reflected without an app restart; @@ -718,6 +710,7 @@ app.setAboutPanelOptions({ // handler removes the size cap and gives the <video> element seekable, // range-aware playback. Must be registered before the app is ready. const MEDIA_PROTOCOL = 'hermes-media' + // Only audio/video may be streamed. Without this the handler would read any // non-blocklisted local file (no size cap) for any `fetch(hermes-media://…)`. const STREAMABLE_MEDIA_EXTS = new Set([ @@ -749,9 +742,12 @@ protocol.registerSchemesAsPrivileged([ function registerMediaProtocol() { protocol.handle(MEDIA_PROTOCOL, async request => { let resolvedPath + try { const url = new URL(request.url) + const filePath = decodeURIComponent(url.pathname.replace(/^\/+/, '')) + ;({ resolvedPath } = await resolveReadableFileForIpc(filePath, { purpose: 'Media stream' })) } catch { return new Response('Media not found', { status: 404 }) @@ -820,6 +816,7 @@ let desktopLogBuffer = '' let desktopLogFlushTimer = null let desktopLogFlushPromise = Promise.resolve() let nativeThemeListenerInstalled = false + let bootProgressState = { error: null, fakeMode: BOOT_FAKE_MODE, @@ -834,32 +831,39 @@ let bootProgressState = { // Each step is ['rm', path] or ['mv', src, dst]; executed best-effort so a // missing chain link never aborts the rest. function planDesktopLogRotation(size) { - if (size < DESKTOP_LOG_MAX_BYTES) return [] + if (size < DESKTOP_LOG_MAX_BYTES) {return []} const backups = n => Array.from({ length: n }, (_, i) => desktopLogBackupPath(i + 1)) + // Pathological boot-loop log: reclaim live + every backup outright. if (size > DESKTOP_LOG_DISCARD_BYTES) { return [DESKTOP_LOG_PATH, ...backups(DESKTOP_LOG_BACKUP_COUNT)].map(p => ['rm', p]) } + // Cascade: drop oldest, shift each up, live -> .1. const ops = [['rm', desktopLogBackupPath(DESKTOP_LOG_BACKUP_COUNT)]] + for (let i = DESKTOP_LOG_BACKUP_COUNT - 1; i >= 1; i--) { ops.push(['mv', desktopLogBackupPath(i), desktopLogBackupPath(i + 1)]) } + ops.push(['mv', DESKTOP_LOG_PATH, desktopLogBackupPath(1)]) + return ops } function rotateDesktopLogIfNeededSync() { let size + try { size = fs.statSync(DESKTOP_LOG_PATH).size } catch { return // No live file yet — the append (re)creates it. } + for (const [op, src, dst] of planDesktopLogRotation(size)) { try { - if (op === 'rm') fs.rmSync(src, { force: true }) - else fs.renameSync(src, dst) + if (op === 'rm') {fs.rmSync(src, { force: true })} + else {fs.renameSync(src, dst)} } catch { // Best-effort — logging must never block startup/shutdown. } @@ -868,15 +872,17 @@ function rotateDesktopLogIfNeededSync() { async function rotateDesktopLogIfNeededAsync() { let size + try { size = (await fs.promises.stat(DESKTOP_LOG_PATH)).size } catch { return // No live file yet — the append (re)creates it. } + for (const [op, src, dst] of planDesktopLogRotation(size)) { try { - if (op === 'rm') await fs.promises.rm(src, { force: true }) - else await fs.promises.rename(src, dst) + if (op === 'rm') {await fs.promises.rm(src, { force: true })} + else {await fs.promises.rename(src, dst)} } catch { // Best-effort — logging must never crash the shell. } @@ -884,7 +890,7 @@ async function rotateDesktopLogIfNeededAsync() { } function flushDesktopLogBufferSync() { - if (!desktopLogBuffer) return + if (!desktopLogBuffer) {return} const chunk = desktopLogBuffer desktopLogBuffer = '' @@ -898,7 +904,7 @@ function flushDesktopLogBufferSync() { } function flushDesktopLogBufferAsync() { - if (!desktopLogBuffer) return desktopLogFlushPromise + if (!desktopLogBuffer) {return desktopLogFlushPromise} const chunk = desktopLogBuffer desktopLogBuffer = '' @@ -916,7 +922,7 @@ function flushDesktopLogBufferAsync() { } function scheduleDesktopLogFlush() { - if (desktopLogFlushTimer) return + if (desktopLogFlushTimer) {return} desktopLogFlushTimer = setTimeout(() => { desktopLogFlushTimer = null void flushDesktopLogBufferAsync() @@ -925,9 +931,11 @@ function scheduleDesktopLogFlush() { function rememberLog(chunk) { const text = String(chunk || '').trim() - if (!text) return + + if (!text) {return} const lines = text.split(/\r?\n/).map(line => `[hermes] ${line}`) hermesLog.push(...lines) + if (hermesLog.length > 300) { hermesLog.splice(0, hermesLog.length - 300) } @@ -939,6 +947,7 @@ function rememberLog(chunk) { clearTimeout(desktopLogFlushTimer) desktopLogFlushTimer = null } + void flushDesktopLogBufferAsync() return @@ -949,9 +958,11 @@ function rememberLog(chunk) { function openExternalUrl(rawUrl) { const raw = String(rawUrl || '').trim() - if (!raw) return false + + if (!raw) {return false} let parsed + try { parsed = new URL(raw) } catch { @@ -965,6 +976,7 @@ function openExternalUrl(rawUrl) { // string), fall back to revealing the file in the system file manager. if (parsed.protocol === 'file:') { let localPath + try { localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open external file' }) } catch { @@ -999,11 +1011,13 @@ function openExternalUrl(rawUrl) { if (IS_WSL) { rememberLog(`[link] opening via WSL→Windows: ${url}`) + const proc = spawn('cmd.exe', ['/c', 'start', '""', url], { detached: true, stdio: 'ignore', windowsHide: true }) + proc.on('error', error => { rememberLog(`[link] cmd.exe start failed: ${error.message}; falling back to xdg-open`) shell.openExternal(url).catch(fallback => rememberLog(`[link] xdg-open failed: ${fallback.message}`)) @@ -1020,9 +1034,11 @@ function openExternalUrl(rawUrl) { async function openPreviewInBrowser(rawUrl) { const raw = String(rawUrl || '').trim() - if (!raw) return false + + if (!raw) {return false} let parsed + try { parsed = new URL(raw) } catch { @@ -1031,6 +1047,7 @@ async function openPreviewInBrowser(rawUrl) { if (parsed.protocol === 'file:') { let localPath + try { localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open preview in browser' }) } catch { @@ -1046,7 +1063,7 @@ async function openPreviewInBrowser(rawUrl) { } function ensureWslWindowsFonts() { - if (!IS_WSL) return + if (!IS_WSL) {return} const fontsDir = ['/mnt/c/Windows/Fonts', '/mnt/c/windows/fonts'].find(candidate => { try { @@ -1055,18 +1072,21 @@ function ensureWslWindowsFonts() { return false } }) - if (!fontsDir) return + + if (!fontsDir) {return} try { const confDir = path.join(app.getPath('home'), '.config', 'fontconfig', 'conf.d') const confPath = path.join(confDir, '99-hermes-wsl-windows-fonts.conf') let existing = '' + try { existing = fs.readFileSync(confPath, 'utf8') } catch { existing = '' } - if (existing.includes(fontsDir)) return + + if (existing.includes(fontsDir)) {return} fs.mkdirSync(confDir, { recursive: true }) fs.writeFileSync( @@ -1089,14 +1109,17 @@ function sleep(ms) { function clampBootProgress(value) { const numeric = Number(value) - if (!Number.isFinite(numeric)) return 0 + + if (!Number.isFinite(numeric)) {return 0} + return Math.max(0, Math.min(100, Math.round(numeric))) } function broadcastBootProgress() { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) {return} webContents.send('hermes:boot-progress', bootProgressState) } @@ -1120,6 +1143,7 @@ function broadcastBootProgress() { // 'Copy output' button gives the user actually-actionable context, not // just the last few lines. const BOOTSTRAP_LOG_RING_MAX = 500 + let bootstrapState = { active: false, manifest: null, @@ -1137,6 +1161,7 @@ function broadcastBootstrapEvent(ev) { bootstrapState.active = true bootstrapState.startedAt = bootstrapState.startedAt || Date.now() bootstrapState.stages = {} + for (const stage of ev.stages || []) { bootstrapState.stages[stage.name] = { state: 'pending', json: null, durationMs: null, error: null } } @@ -1149,6 +1174,7 @@ function broadcastBootstrapEvent(ev) { } } else if (ev.type === 'log') { bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line, stream: ev.stream || 'stdout' }) + if (bootstrapState.log.length > BOOTSTRAP_LOG_RING_MAX) { bootstrapState.log.splice(0, bootstrapState.log.length - BOOTSTRAP_LOG_RING_MAX) } @@ -1170,9 +1196,10 @@ function broadcastBootstrapEvent(ev) { } } - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) {return} webContents.send('hermes:bootstrap:event', ev) } @@ -1180,9 +1207,10 @@ function getBootstrapState() { return bootstrapState } -function updateBootProgress(update, options = {}) { +function updateBootProgress(update, options: {allowDecrease?: boolean} = {}) { const nextProgressRaw = typeof update.progress === 'number' ? clampBootProgress(update.progress) : bootProgressState.progress + const nextProgress = options.allowDecrease ? nextProgressRaw : Math.max(bootProgressState.progress, nextProgressRaw) bootProgressState = { @@ -1242,7 +1270,7 @@ function directoryExists(filePath) { // cycle loops. Instead the fresh instance parks until the update finishes, then // brings the backend up itself (it is the surviving instance — the updater's // own relaunch hits our single-instance lock and quits). Marker parsing + -// staleness self-heal live in update-marker.cjs (unit-tested). +// staleness self-heal live in update-marker.ts (unit-tested). // How long we'll park the launch waiting for a live update to finish before // giving up and starting the backend anyway (belt-and-suspenders alongside the @@ -1263,10 +1291,12 @@ const UPDATE_HANDOFF_DWELL_MS = 2500 // rather than a frozen splash. Returns true if it parked at all. async function waitForUpdateToFinish() { let marker = readLiveUpdateMarker(HERMES_HOME) - if (!marker) return false + + if (!marker) {return false} rememberLog(`[updates] update in progress (pid=${marker.pid}); deferring backend start until it finishes`) const deadline = Date.now() + UPDATE_WAIT_TIMEOUT_MS + while (marker && Date.now() < deadline) { await advanceBootProgress( 'backend.update-wait', @@ -1276,11 +1306,13 @@ async function waitForUpdateToFinish() { await new Promise(r => setTimeout(r, UPDATE_WAIT_POLL_MS)) marker = readLiveUpdateMarker(HERMES_HOME) } + if (marker) { rememberLog('[updates] update still in progress after wait timeout; starting backend anyway') } else { rememberLog('[updates] update finished; proceeding with backend start') } + return true } @@ -1289,17 +1321,20 @@ function unpackedPathFor(filePath) { } function findOnPath(command) { - if (!command) return null + if (!command) {return null} if (path.isAbsolute(command) || command.includes(path.sep) || (IS_WINDOWS && command.includes('/'))) { - if (!fileExists(command)) return null - if (isWindowsBinaryPathInWsl(command, { isWsl: IS_WSL })) return null + if (!fileExists(command)) {return null} + + if (isWindowsBinaryPathInWsl(command, { isWsl: IS_WSL })) {return null} + return command } const pathEntries = String(process.env.PATH || '') .split(path.delimiter) .filter(Boolean) + // On Windows, try PATHEXT extensions BEFORE the bare (empty-extension) name. // A real command must resolve via its .exe/.cmd (Windows command-resolution // semantics consult PATHEXT); an extensionless file — e.g. a Git-Bash @@ -1313,7 +1348,8 @@ function findOnPath(command) { for (const entry of pathEntries) { for (const extension of extensions) { const candidate = path.join(entry, `${command}${extension}`) - if (fileExists(candidate)) return candidate + + if (fileExists(candidate)) {return candidate} } } @@ -1325,17 +1361,20 @@ function isCommandScript(command) { } function unwrapWindowsVenvHermesCommand(command, backendArgs) { - if (!IS_WINDOWS || !command || isCommandScript(command)) return null + if (!IS_WINDOWS || !command || isCommandScript(command)) {return null} const resolved = path.resolve(String(command)) - if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) return null + + if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) {return null} const scriptsDir = path.dirname(resolved) - if (path.basename(scriptsDir).toLowerCase() !== 'scripts') return null + + if (path.basename(scriptsDir).toLowerCase() !== 'scripts') {return null} const venvRoot = path.dirname(scriptsDir) const python = getVenvPython(venvRoot) - if (!fileExists(python)) return null + + if (!fileExists(python)) {return null} const root = path.dirname(venvRoot) @@ -1357,6 +1396,7 @@ function unwrapWindowsVenvHermesCommand(command, backendArgs) { rememberLog( `Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.` ) + return null } @@ -1389,12 +1429,15 @@ function unwrapWindowsVenvHermesCommand(command, backendArgs) { // (covers a bare `hermes` resolved from PATH with no known source root). Result // is cached per resolved runtime so we probe at most once per backend. const _serveSupportCache = new Map() + function backendSupportsServe(backend) { - if (!backend || !backend.command) return true + if (!backend || !backend.command) {return true} const key = `${backend.command}::${backend.root || ''}` - if (_serveSupportCache.has(key)) return _serveSupportCache.get(key) + + if (_serveSupportCache.has(key)) {return _serveSupportCache.get(key)} let supported = null + if (backend.root) { try { const src = fs.readFileSync(path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), 'utf8') @@ -1424,6 +1467,7 @@ function backendSupportsServe(backend) { rememberLog( `[backend] \`serve\` ${supported ? 'supported' : 'unsupported → routing via legacy `dashboard`'} for ${backend.label || key}` ) + return supported } @@ -1435,9 +1479,10 @@ function getBackendArgsForRuntime(backend) { } function normalizeExecutablePathForCompare(commandPath) { - if (!commandPath) return null + if (!commandPath) {return null} let resolved = path.resolve(String(commandPath)) + try { resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved) } catch { @@ -1448,15 +1493,17 @@ function normalizeExecutablePathForCompare(commandPath) { } function looksLikeDesktopAppBinary(commandPath) { - if (!IS_WINDOWS || !commandPath) return false + if (!IS_WINDOWS || !commandPath) {return false} const normalizedCandidate = normalizeExecutablePathForCompare(commandPath) const normalizedCurrentExec = normalizeExecutablePathForCompare(process.execPath) + if (normalizedCandidate && normalizedCurrentExec && normalizedCandidate === normalizedCurrentExec) { return true } let resolved = path.resolve(String(commandPath)) + try { resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved) } catch { @@ -1464,6 +1511,7 @@ function looksLikeDesktopAppBinary(commandPath) { } const resourcesDir = path.join(path.dirname(resolved), 'resources') + return ( fileExists(path.join(resourcesDir, 'app.asar')) || directoryExists(path.join(resourcesDir, 'app.asar.unpacked')) ) @@ -1475,7 +1523,8 @@ function isHermesSourceRoot(root) { function findPythonForRoot(root) { const override = process.env.HERMES_DESKTOP_PYTHON - if (override && fileExists(override)) return override + + if (override && fileExists(override)) {return override} const relativePaths = IS_WINDOWS ? [path.join('.venv', 'Scripts', 'python.exe'), path.join('venv', 'Scripts', 'python.exe')] @@ -1483,7 +1532,8 @@ function findPythonForRoot(root) { for (const relativePath of relativePaths) { const candidate = path.join(root, relativePath) - if (fileExists(candidate)) return candidate + + if (fileExists(candidate)) {return candidate} } return findSystemPython() @@ -1494,8 +1544,10 @@ function findSystemPython() { // POSIX systems: PATH lookup is safe. for (const command of ['python3', 'python']) { const candidate = findOnPath(command) - if (candidate) return candidate + + if (candidate) {return candidate} } + return null } @@ -1551,12 +1603,15 @@ function findSystemPython() { ['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'], hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) ) + // Output format: " (Default) REG_SZ C:\Path\To\Python\" const match = out.match(/REG_SZ\s+(.+?)\s*$/m) + if (match) { const installPath = match[1].trim() const pythonExe = path.join(installPath, 'python.exe') - if (fileExists(pythonExe)) return pythonExe + + if (fileExists(pythonExe)) {return pythonExe} } } catch { // Key not present — try next. @@ -1567,12 +1622,16 @@ function findSystemPython() { // Pass 2: filesystem probe of standard locations. const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files' const localAppData = process.env.LOCALAPPDATA || '' + for (const versionDir of SUPPORTED_VERSIONS_NO_DOT) { const systemWide = path.join(programFiles, `Python${versionDir}`, 'python.exe') - if (fileExists(systemWide)) return systemWide + + if (fileExists(systemWide)) {return systemWide} + if (localAppData) { const perUser = path.join(localAppData, 'Programs', 'Python', `Python${versionDir}`, 'python.exe') - if (fileExists(perUser)) return perUser + + if (fileExists(perUser)) {return perUser} } } @@ -1582,6 +1641,7 @@ function findSystemPython() { // the requested version. We try in version-priority order so the // first hit wins. const pyExe = findOnPath('py.exe') + if (pyExe) { for (const version of SUPPORTED_VERSIONS) { try { @@ -1593,8 +1653,10 @@ function findSystemPython() { stdio: ['ignore', 'pipe', 'ignore'] }) ) + const candidate = out.trim() - if (candidate && fileExists(candidate)) return candidate + + if (candidate && fileExists(candidate)) {return candidate} } catch { // py couldn't find that version — try next. } @@ -1628,6 +1690,7 @@ function findGitBash() { // start probing system-wide locations. const localAppData = process.env.LOCALAPPDATA || '' const candidates = [] + if (localAppData) { candidates.push(path.join(localAppData, 'hermes', 'git', 'bin', 'bash.exe')) candidates.push(path.join(localAppData, 'hermes', 'git', 'usr', 'bin', 'bash.exe')) @@ -1636,12 +1699,13 @@ function findGitBash() { // Standard Git for Windows install locations. candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'Git', 'bin', 'bash.exe')) candidates.push(path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe')) + if (localAppData) { candidates.push(path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe')) } for (const candidate of candidates) { - if (fileExists(candidate)) return candidate + if (fileExists(candidate)) {return candidate} } // Last resort — bash on PATH (covers WSL bash, MSYS2, custom installs). @@ -1677,11 +1741,14 @@ function getVenvPython(venvRoot) { function getVenvSitePackagesEntries(venvRoot) { const entries = [] - if (!venvRoot) return entries + + if (!venvRoot) {return entries} if (IS_WINDOWS) { const sitePackages = path.join(venvRoot, 'Lib', 'site-packages') - if (directoryExists(sitePackages)) entries.push(sitePackages) + + if (directoryExists(sitePackages)) {entries.push(sitePackages)} + return entries } @@ -1689,21 +1756,26 @@ function getVenvSitePackagesEntries(venvRoot) { try { const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8') const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im) + return match ? match[1].trim() : null } catch { return null } })() + if (version) { const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages') - if (directoryExists(sitePackages)) entries.push(sitePackages) + + if (directoryExists(sitePackages)) {entries.push(sitePackages)} } + return entries } function makeDashboardReadyFile() { const dir = path.join(app.getPath('userData'), 'backend-ready') fs.mkdirSync(dir, { recursive: true }) + return path.join(dir, `dashboard-${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.json`) } @@ -1713,26 +1785,33 @@ function makeDashboardReadyFile() { // "Couldn't check for updates". Mirror findGitBash: PortableGit first, then // standard Git-for-Windows locations, then PATH. Cached after first probe. let _gitBinaryCache = null + function resolveGitBinary() { - if (_gitBinaryCache) return _gitBinaryCache + if (_gitBinaryCache) {return _gitBinaryCache} + if (!IS_WINDOWS) { _gitBinaryCache = findOnPath('git') || 'git' + return _gitBinaryCache } const localAppData = process.env.LOCALAPPDATA || '' const candidates = [] + if (localAppData) { candidates.push(path.join(localAppData, 'hermes', 'git', 'cmd', 'git.exe')) candidates.push(path.join(localAppData, 'hermes', 'git', 'bin', 'git.exe')) } + candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'Git', 'cmd', 'git.exe')) candidates.push(path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'cmd', 'git.exe')) + if (localAppData) { candidates.push(path.join(localAppData, 'Programs', 'Git', 'cmd', 'git.exe')) } _gitBinaryCache = candidates.find(fileExists) || findOnPath('git') || 'git' + return _gitBinaryCache } @@ -1741,13 +1820,15 @@ function resolveGitBinary() { // lives, so a bare spawn('gh') ENOENTs even though `gh` works in the user's // terminal. Check the common install locations first, then PATH. Cached. let _ghBinaryCache = null + function resolveGhBinary() { - if (_ghBinaryCache) return _ghBinaryCache + if (_ghBinaryCache) {return _ghBinaryCache} const candidates = [] if (IS_WINDOWS) { candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'GitHub CLI', 'gh.exe')) + if (process.env.LOCALAPPDATA) { candidates.push(path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links', 'gh.exe')) } @@ -1757,6 +1838,7 @@ function resolveGhBinary() { } _ghBinaryCache = candidates.find(fileExists) || findOnPath('gh') || 'gh' + return _ghBinaryCache } @@ -1770,6 +1852,7 @@ function readDesktopUpdateConfig() { try { const parsed = JSON.parse(fs.readFileSync(DESKTOP_UPDATE_CONFIG_PATH, 'utf8')) const branch = typeof parsed?.branch === 'string' ? parsed.branch.trim() : '' + return { branch: branch || DEFAULT_UPDATE_BRANCH } } catch { return { branch: DEFAULT_UPDATE_BRANCH } @@ -1778,7 +1861,7 @@ function readDesktopUpdateConfig() { // Atomic file write: temp + rename (atomic on all platforms). Prevents // partial writes on crash/power loss that corrupt JSON config files. -function writeFileAtomic(targetPath, data, encoding) { +function writeFileAtomic(targetPath, data, encoding?: BufferEncoding) { const tmp = targetPath + '.tmp' fs.writeFileSync(tmp, data, encoding) fs.renameSync(tmp, targetPath) @@ -1803,7 +1886,8 @@ function readWindowState() { // getNormalBounds() keeps the pre-maximize size, so un-maximizing next session // lands back where the user actually sized the window. function persistWindowState() { - if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isMinimized()) return + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isMinimized()) {return} + try { const { x, y, width, height } = mainWindow.getNormalBounds() fs.mkdirSync(path.dirname(DESKTOP_WINDOW_STATE_PATH), { recursive: true }) @@ -1832,14 +1916,14 @@ function resolveUpdateRoot() { return candidates.find(c => directoryExists(path.join(c, '.git'))) || candidates[0] || ACTIVE_HERMES_ROOT } -function runGit(args, options = {}) { +function runGit(args, options: any = {}): Promise<{code: number, stdout: string, stderr: string}> { return new Promise((resolve, reject) => { const child = spawn( resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({ cwd: options.cwd, - env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' }, + env: { ...process.env, ...(options.env || {}) as any, GIT_TERMINAL_PROMPT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] }) ) @@ -1865,12 +1949,14 @@ const firstLine = text => (text || '').split('\n').find(Boolean) || '' async function getOriginUrl(updateRoot) { const origin = await runGit(['remote', 'get-url', 'origin'], { cwd: updateRoot }) + return origin.code === 0 ? origin.stdout.trim() : '' } function emitUpdateProgress(payload) { const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() } rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`) + for (const window of BrowserWindow.getAllWindows()) { window.webContents.send('hermes:updates:progress', merged) } @@ -1890,15 +1976,18 @@ async function resolveHealedBranch(updateRoot, branch) { const originUrl = await getOriginUrl(updateRoot) const remote = isOfficialSshRemote(originUrl) ? OFFICIAL_REPO_HTTPS_URL : 'origin' const probe = await runGit(['ls-remote', '--exit-code', '--heads', remote, branch], { cwd: updateRoot }) + if (probe.code !== 2) { return branch } rememberLog(`[updates] origin/${branch} is gone (merged?); falling back to main`) const config = readDesktopUpdateConfig() + if (config.branch !== 'main') { writeDesktopUpdateConfig({ ...config, branch: 'main' }) } + return 'main' } @@ -1906,6 +1995,7 @@ async function checkUpdates() { const updateRoot = resolveUpdateRoot() let { branch } = readDesktopUpdateConfig() const gitDir = path.join(updateRoot, '.git') + if (!directoryExists(gitDir)) { return { supported: false, @@ -1918,15 +2008,19 @@ async function checkUpdates() { branch = await resolveHealedBranch(updateRoot, branch) const originUrl = await getOriginUrl(updateRoot) + if (isOfficialSshRemote(originUrl)) { const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim()) + const [currentSha, target, dirtyStr, currentBranch] = await Promise.all([ git(['rev-parse', 'HEAD']), runGit(['ls-remote', OFFICIAL_REPO_HTTPS_URL, `refs/heads/${branch}`], { cwd: updateRoot }), git(['status', '--porcelain']), git(['rev-parse', '--abbrev-ref', 'HEAD']) ]) + const targetSha = firstLine(target.stdout).split(/\s+/)[0] || '' + if (target.code !== 0 || !targetSha) { return { supported: true, @@ -1937,6 +2031,7 @@ async function checkUpdates() { fetchedAt: Date.now() } } + return { supported: true, branch, @@ -1952,6 +2047,7 @@ async function checkUpdates() { } const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot }) + if (fetched.code !== 0) { return { supported: true, @@ -1964,6 +2060,7 @@ async function checkUpdates() { } const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim()) + const [currentSha, targetSha, dirtyStr, currentBranch, shallowStr, mergeBaseStr] = await Promise.all([ git(['rev-parse', 'HEAD']), git(['rev-parse', `origin/${branch}`]), @@ -1977,6 +2074,7 @@ async function checkUpdates() { const isShallow = shallowStr === 'true' const hasMergeBase = Boolean(mergeBaseStr) + // Only enumerate the commit count when it is meaningful. On a shallow checkout // with no merge-base, `rev-list --count` walks the entire remote ancestry // (thousands of commits, see #51922) and resolveBehindCount discards the @@ -1992,6 +2090,7 @@ async function checkUpdates() { isShallow, hasMergeBase }) + const commits = behind > 0 ? await readCommitLog(updateRoot, branch) : [] return { @@ -2011,6 +2110,7 @@ async function checkUpdates() { async function readCommitLog(cwd, branch) { const SEP = '\x1f' const REC = '\x1e' + const { stdout } = await runGit( ['log', `HEAD..origin/${branch}`, `--pretty=format:%H${SEP}%s${SEP}%an${SEP}%at${REC}`, '-n', '40'], { cwd } @@ -2022,6 +2122,7 @@ async function readCommitLog(cwd, branch) { .filter(Boolean) .map(line => { const [sha, summary, author, at] = line.split(SEP) + return { sha, summary, author, at: Number.parseInt(at, 10) * 1000 } }) } @@ -2048,11 +2149,12 @@ let isQuittingForHandoff = false function resolveUpdaterBinary() { const name = IS_WINDOWS ? 'hermes-setup.exe' : 'hermes-setup' const candidate = path.join(HERMES_HOME, name) + return fileExists(candidate) ? candidate : null } function repairMacUpdaterHelper(updater) { - if (!IS_MAC || !updater) return + if (!IS_MAC || !updater) {return} try { execFileSync('/usr/bin/xattr', ['-cr', updater], { stdio: 'ignore' }) @@ -2062,6 +2164,7 @@ function repairMacUpdaterHelper(updater) { try { execFileSync('/usr/bin/codesign', ['--verify', updater], { stdio: 'ignore' }) + return } catch { // Unsigned or invalid helper. Apply a local ad-hoc signature so Gatekeeper @@ -2090,10 +2193,12 @@ function venvHermesShimPath(updateRoot) { // this practically always succeeds (no mandatory locking), so it returns false // — correct, since the shim-contention brick is Windows-only. function isShimLocked(shimPath) { - if (!IS_WINDOWS) return false + if (!IS_WINDOWS) {return false} let fd + try { fd = fs.openSync(shimPath, 'r+') + return false } catch (err) { // ENOENT ⇒ not there ⇒ nothing locking it. Anything else (EBUSY/EPERM/ @@ -2119,8 +2224,10 @@ function isShimLocked(shimPath) { // not a process-group leader — a POSIX negative-pgid kill would be meaningless // here anyway). POSIX teardown stays with the existing before-quit SIGTERM. function forceKillProcessTree(pid) { - if (!IS_WINDOWS) return - if (!Number.isInteger(pid) || pid <= 0) return + if (!IS_WINDOWS) {return} + + if (!Number.isInteger(pid) || pid <= 0) {return} + try { execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' })) } catch { @@ -2160,13 +2267,15 @@ async function releaseBackendLockForUpdate(updateRoot) { // `tag` only flavors the log lines. No-op off Windows (POSIX has no mandatory // locks — the before-quit SIGTERM + the cleanup script's own PID-wait suffice). async function releaseBackendLock(updateRoot, tag) { - if (!IS_WINDOWS) return { unlocked: true } + if (!IS_WINDOWS) {return { unlocked: true }} // Collect every backend PID the desktop owns: primary window backend + pool. const pids = [] - if (hermesProcess && Number.isInteger(hermesProcess.pid)) pids.push(hermesProcess.pid) + + if (hermesProcess && Number.isInteger(hermesProcess.pid)) {pids.push(hermesProcess.pid)} + for (const entry of backendPool.values()) { - if (entry.process && Number.isInteger(entry.process.pid)) pids.push(entry.process.pid) + if (entry.process && Number.isInteger(entry.process.pid)) {pids.push(entry.process.pid)} } // Graceful first (lets Python flush), then tree-kill to catch grandchildren. @@ -2177,27 +2286,36 @@ async function releaseBackendLock(updateRoot, tag) { void 0 } } + stopAllPoolBackends() - for (const pid of pids) forceKillProcessTree(pid) + + for (const pid of pids) {forceKillProcessTree(pid)} const shim = venvHermesShimPath(updateRoot) const deadlineMs = Date.now() + 15000 + while (Date.now() < deadlineMs) { if (!isShimLocked(shim)) { rememberLog(`[${tag}] venv shim unlocked; safe to proceed`) + return { unlocked: true } } + // A supervised backend can respawn between kill and check (grandchildren, // pool entries registered mid-teardown). Re-collect and re-kill each pass // instead of trusting the initial sweep. const stragglers = [] - if (hermesProcess && Number.isInteger(hermesProcess.pid)) stragglers.push(hermesProcess.pid) + + if (hermesProcess && Number.isInteger(hermesProcess.pid)) {stragglers.push(hermesProcess.pid)} + for (const entry of backendPool.values()) { - if (entry.process && Number.isInteger(entry.process.pid)) stragglers.push(entry.process.pid) + if (entry.process && Number.isInteger(entry.process.pid)) {stragglers.push(entry.process.pid)} } - for (const pid of stragglers) forceKillProcessTree(pid) + + for (const pid of stragglers) {forceKillProcessTree(pid)} await new Promise(r => setTimeout(r, 300)) } + // Do NOT proceed past a held lock: handing off to the updater while another // process (a second desktop window, a user terminal, an unkillable child) // still maps the venv's files guarantees a half-updated venv — the updater's @@ -2206,6 +2324,7 @@ async function releaseBackendLock(updateRoot, tag) { // the update loudly and keeping the app running is strictly better than a // bricked install that needs manual venv surgery. rememberLog(`[${tag}] venv shim still locked after 15s; aborting hand-off (something outside this app holds the venv)`) + return { unlocked: false } } @@ -2223,10 +2342,12 @@ async function applyUpdates(opts = {}) { if (updateInFlight) { throw new Error('An update is already in progress.') } + updateInFlight = true try { const updater = resolveUpdaterBinary() + if (!updater && !IS_WINDOWS) { // macOS/Linux drag-install: no staged Tauri hermes-setup. Unlike Windows // (where a venv-shim file lock forces the quit→hand-off→rebuild dance), @@ -2236,6 +2357,7 @@ async function applyUpdates(opts = {}) { // with the freshly built one and relaunch. return await applyUpdatesPosixInApp(opts) } + if (!updater) { // No staged updater binary — this is a CLI-installed user (they ran // `hermes desktop`, never the Tauri installer that self-copies @@ -2248,18 +2370,23 @@ async function applyUpdates(opts = {}) { // checkouts, keep it bare for main so the card stays clean. const updateRoot = resolveUpdateRoot() let command = 'hermes update' + try { const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot }) const current = (head.stdout || '').trim() + if (head.code === 0 && current && current !== 'HEAD') { const branch = await resolveHealedBranch(updateRoot, current) - if (branch !== 'main') command = `hermes update --branch ${branch}` + + if (branch !== 'main') {command = `hermes update --branch ${branch}`} } } catch { // Best-effort: fall back to bare `hermes update` if branch detection fails. } + rememberLog(`[updates] no staged updater; surfacing manual \`${command}\` for CLI install at ${updateRoot}`) emitUpdateProgress({ stage: 'manual', message: command, percent: null }) + return { ok: true, manual: true, command, hermesRoot: updateRoot } } @@ -2276,9 +2403,11 @@ async function applyUpdates(opts = {}) { const branch = await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH) const updaterArgs = ['--update', '--branch', branch] const targetApp = IS_MAC ? runningAppBundle() : null + if (targetApp) { updaterArgs.push('--target-app', targetApp) } + const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') // Stop our own backend(s) and wait for the venv shim to unlock BEFORE we @@ -2286,6 +2415,7 @@ async function applyUpdates(opts = {}) { // hermes.exe (held by the backend child / its grandchildren) and the update // bricks. See releaseBackendLockForUpdate for the full failure analysis. const lock = await releaseBackendLockForUpdate(updateRoot) + if (!lock.unlocked) { // Something OUTSIDE this app holds the venv (a second window, a user // terminal running hermes, an unkillable child). Handing off anyway @@ -2295,8 +2425,10 @@ async function applyUpdates(opts = {}) { const message = 'Update aborted: another process is holding the Hermes install open ' + '(a second Hermes window or a terminal running hermes?). Close it and retry.' + emitUpdateProgress({ stage: 'error', message, percent: null }) startHermes().catch(() => {}) + return { ok: false, error: message } } @@ -2313,6 +2445,7 @@ async function applyUpdates(opts = {}) { stdio: 'ignore', windowsHide: false }) + child.unref() // Write the update-in-progress marker IMMEDIATELY — before the 2.5s @@ -2345,19 +2478,23 @@ async function applyUpdates(opts = {}) { } async function handOffWindowsBootstrapRecovery(reason) { - if (!IS_WINDOWS || !IS_PACKAGED) return false + if (!IS_WINDOWS || !IS_PACKAGED) {return false} const updater = resolveUpdaterBinary() - if (!updater) return false + + if (!updater) {return false} const updateRoot = resolveUpdateRoot() const { branch: configuredBranch } = readDesktopUpdateConfig() + const branch = directoryExists(path.join(updateRoot, '.git')) ? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH) : configuredBranch || DEFAULT_UPDATE_BRANCH + const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes') const venvPython = path.join(venvBin, IS_WINDOWS ? 'python.exe' : 'python') + // Choose the gentle in-place --update when ANY real-install signal is present, // not just the `hermes.exe` console-script shim. That shim is generated at the // END of venv setup and is absent in exactly the interrupted/quarantined states @@ -2366,6 +2503,7 @@ async function handOffWindowsBootstrapRecovery(reason) { // and the bootstrap-complete marker are present earlier and are better signals. const haveRealInstall = fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')) + const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] await releaseBackendLockForUpdate(updateRoot) @@ -2381,6 +2519,7 @@ async function handOffWindowsBootstrapRecovery(reason) { stdio: 'ignore', windowsHide: false }) + child.unref() // Same marker pre-write as applyUpdates — see comment there. The recovery @@ -2408,14 +2547,17 @@ async function handOffWindowsBootstrapRecovery(reason) { // the install we're updating, fall back to `hermes` on PATH. function resolveHermesCliBinary(updateRoot) { const venvHermes = path.join(updateRoot, 'venv', 'bin', 'hermes') - if (fileExists(venvHermes)) return venvHermes + + if (fileExists(venvHermes)) {return venvHermes} + return findOnPath('hermes') || null } // Spawn a command and stream each output line to the update progress channel. -function runStreamedUpdate(command, args, { cwd, env, stage } = {}) { +function runStreamedUpdate(command, args, { cwd, env, stage }: any = {}) { return new Promise(resolve => { let child + try { child = spawn( command, @@ -2428,14 +2570,18 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) { ) } catch (err) { resolve({ code: 1, error: err.message }) + return } + const emitLines = chunk => { for (const line of chunk.toString().split('\n')) { const trimmed = line.trim() - if (trimmed) emitUpdateProgress({ stage, message: trimmed, percent: null }) + + if (trimmed) {emitUpdateProgress({ stage, message: trimmed, percent: null })} } } + child.stdout.on('data', emitLines) child.stderr.on('data', emitLines) child.once('error', err => resolve({ code: 1, error: err.message })) @@ -2446,9 +2592,11 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) { // The running app's .app bundle (packaged macOS): execPath is // <App>.app/Contents/MacOS/<exe>; climb three levels to the bundle root. function runningAppBundle() { - if (!IS_MAC) return null + if (!IS_MAC) {return null} let dir = path.dirname(app.getPath('exe')) // .../Contents/MacOS - for (let i = 0; i < 2; i++) dir = path.dirname(dir) // -> .../X.app + + for (let i = 0; i < 2; i++) {dir = path.dirname(dir)} // -> .../X.app + return dir.endsWith('.app') ? dir : null } @@ -2460,18 +2608,20 @@ function shellQuote(value) { // (`hermes desktop --build-only`), then atomically swap the running .app bundle // with the freshly built one and relaunch. Degrades to "backend updated, // restart to load the new GUI" if the swap can't be performed. -async function applyUpdatesPosixInApp() { +async function applyUpdatesPosixInApp(opts: any) { const updateRoot = resolveUpdateRoot() const hermes = resolveHermesCliBinary(updateRoot) + if (!hermes) { emitUpdateProgress({ stage: 'manual', message: 'hermes update', percent: null }) + return { ok: true, manual: true, command: 'hermes update', hermesRoot: updateRoot } } // Put the Hermes-managed Node and the venv on PATH so `hermes desktop`'s // npm build can find them on a machine with no system Node. Windows portable // Node lives directly under %LOCALAPPDATA%\hermes\node, not node\bin. - const env = { + const env: Record<string, string> = { HERMES_HOME, PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin')) } @@ -2488,14 +2638,17 @@ async function applyUpdatesPosixInApp() { // the update reaper. _kill_stale_dashboard_processes accepts a comma-separated // list (a single int still parses for back-compat). const desktopChildPids = [] + if (hermesProcess && Number.isInteger(hermesProcess.pid)) { desktopChildPids.push(hermesProcess.pid) } + for (const entry of backendPool.values()) { if (entry.process && Number.isInteger(entry.process.pid)) { desktopChildPids.push(entry.process.pid) } } + if (desktopChildPids.length) { env.HERMES_DESKTOP_CHILD_PID = desktopChildPids.join(',') } @@ -2503,9 +2656,11 @@ async function applyUpdatesPosixInApp() { // Branch-pin so a non-main checkout doesn't get switched to main (and self-heal // to main when the pinned branch no longer exists on origin). let branchArgs = [] + try { const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot }) const current = (head.stdout || '').trim() + if (head.code === 0 && current && current !== 'HEAD') { branchArgs = ['--branch', await resolveHealedBranch(updateRoot, current)] } @@ -2514,17 +2669,21 @@ async function applyUpdatesPosixInApp() { } emitUpdateProgress({ stage: 'update', message: 'Updating Hermes (git + dependencies)…', percent: 10 }) + const updated = await runStreamedUpdate(hermes, ['update', '--yes', ...branchArgs], { cwd: updateRoot, env, stage: 'update' - }) + }) as any + if (updated.code !== 0) { emitUpdateProgress({ stage: 'error', message: 'hermes update failed.', error: updated.error || 'update-failed' }) + return { ok: false, error: 'hermes update failed' } } emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 }) + // Retry-once: a first rebuild can fail on a still-settling tree or a // self-healed (network-blocked) Electron download; a second run builds clean // off the healed dist so we reach the swap+relaunch below instead of bailing. @@ -2532,14 +2691,17 @@ async function applyUpdatesPosixInApp() { if (attempt > 0) { emitUpdateProgress({ stage: 'rebuild', message: 'Retrying the desktop rebuild…', percent: 60 }) } + return runStreamedUpdate(hermes, ['desktop', '--build-only'], { cwd: updateRoot, env, stage: 'rebuild' }) }) + if (rebuilt.code !== 0) { emitUpdateProgress({ stage: 'error', message: 'Backend updated, but the desktop rebuild failed. Restart Hermes to retry.', error: rebuilt.error || 'rebuild-failed' }) + return { ok: false, backendUpdated: true, error: 'desktop rebuild failed' } } @@ -2547,7 +2709,7 @@ async function applyUpdatesPosixInApp() { // rebuilds the unpacked app in place under apps/desktop/release/<plat>-unpacked. // We can only HONESTLY relaunch into the new GUI when the *running* binary IS // that rebuilt one — i.e. execPath lives under release/<plat>-unpacked. The - // outcome is decided by three signals (see update-relaunch.cjs): + // outcome is decided by three signals (see update-relaunch.ts): // // underUnpacked + sandboxOk → 'relaunch': detached watcher re-execs us in // place (mirrors the macOS handoff). Without it the update succeeds but @@ -2569,8 +2731,10 @@ async function applyUpdatesPosixInApp() { const preflight = underUnpacked ? sandboxPreflight(unpackedDir, p => fs.statSync(p)) : { ok: false, reason: 'not-under-unpacked', path: null } + const sandboxFallback = sandboxFallbackFromEnv(process.env, process.argv.slice(1)) const sandboxOk = preflight.ok || sandboxFallback + if (underUnpacked && !preflight.ok) { rememberLog( `[updates] sandbox preflight: not launchable (${preflight.reason}) at ${preflight.path}; ` + @@ -2588,6 +2752,7 @@ async function applyUpdatesPosixInApp() { // relaunched instance comes up with default context instead of the user's. const relaunchArgs = collectRelaunchArgs(process.argv.slice(1)) const relaunchEnv = collectRelaunchEnv(process.env) + const relaunchScript = buildRelaunchScript({ pid: process.pid, execPath: process.execPath, @@ -2595,7 +2760,9 @@ async function applyUpdatesPosixInApp() { env: relaunchEnv, cwd: process.cwd() }) + const scriptPath = path.join(app.getPath('temp'), `hermes-desktop-update-${Date.now()}.sh`) + try { fs.writeFileSync(scriptPath, relaunchScript, { mode: 0o755 }) const child = spawn('/bin/bash', [scriptPath], { detached: true, stdio: 'ignore' }) @@ -2606,9 +2773,11 @@ async function applyUpdatesPosixInApp() { ) isQuittingForHandoff = true setTimeout(() => app.quit(), UPDATE_HANDOFF_DWELL_MS) + return { ok: true, handedOff: true } } catch (err) { rememberLog(`[updates] linux relaunch failed: ${err.message}; falling back to manual restart`) + return { ok: true, backendUpdated: true, @@ -2631,6 +2800,7 @@ async function applyUpdatesPosixInApp() { `[updates] gui/backend skew: execPath ${process.execPath} not under release/*-unpacked; ` + 'backend updated, GUI package unchanged (AppImage/.deb/.rpm/dev/unresolved)' ) + return { ok: true, backendUpdated: true, guiUpdated: false, guiSkew: true } } @@ -2640,6 +2810,7 @@ async function applyUpdatesPosixInApp() { `[updates] sandbox not launchable (${preflight.reason}); skipping auto-relaunch, ` + 'returning manual-restart so the user keeps a working window' ) + return { ok: true, backendUpdated: true, @@ -2656,6 +2827,7 @@ async function applyUpdatesPosixInApp() { path.join(updateRoot, 'apps', 'desktop', 'release', 'mac-arm64', 'Hermes.app'), path.join(updateRoot, 'apps', 'desktop', 'release', 'mac', 'Hermes.app') ].find(directoryExists) + const targetApp = runningAppBundle() // No bundle to swap (dev run, Linux AppImage, or unresolved paths): the @@ -2666,6 +2838,7 @@ async function applyUpdatesPosixInApp() { message: 'Backend updated. Restart Hermes to load the new version.', percent: 100 }) + return { ok: true, backendUpdated: true, rebuiltApp: rebuiltApp || null } } @@ -2693,7 +2866,9 @@ fi /usr/bin/xattr -dr com.apple.quarantine "$DST" 2>/dev/null || true /usr/bin/open "$DST" ` + const scriptPath = path.join(app.getPath('temp'), `hermes-desktop-update-${Date.now()}.sh`) + try { fs.writeFileSync(scriptPath, swapScript, { mode: 0o755 }) } catch (err) { @@ -2703,6 +2878,7 @@ fi percent: 100 }) rememberLog(`[updates] could not write swap script: ${err.message}; rebuilt app at ${rebuiltApp}`) + return { ok: true, backendUpdated: true, rebuiltApp } } @@ -2712,6 +2888,7 @@ fi isQuittingForHandoff = true setTimeout(() => app.quit(), 600) + return { ok: true, handedOff: true, rebuiltApp, targetApp } } @@ -2748,6 +2925,7 @@ function readBootstrapMarker() { // "already installed" off the filesystem alone, not just the marker. function isActiveRuntimeUsable() { const venvPython = getVenvPython(VENV_ROOT) + return ( isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(venvPython) && @@ -2761,9 +2939,13 @@ function isActiveRuntimeUsable() { function isBootstrapComplete() { const marker = readBootstrapMarker() - if (!marker || typeof marker !== 'object') return false - if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) return false - if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) return false + + if (!marker || typeof marker !== 'object') {return false} + + if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) {return false} + + if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {return false} + // We DELIBERATELY do NOT verify that the checkout is currently at the // pinned commit -- users update via the in-app update path or `hermes // update`, which moves HEAD legitimately. The marker just attests "we @@ -2776,6 +2958,7 @@ function isBootstrapComplete() { function writeBootstrapMarker(payload) { fs.mkdirSync(path.dirname(BOOTSTRAP_COMPLETE_MARKER), { recursive: true }) + const merged = { schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION, pinnedCommit: payload.pinnedCommit || null, @@ -2783,16 +2966,20 @@ function writeBootstrapMarker(payload) { completedAt: new Date().toISOString(), desktopVersion: app.getVersion() } + writeFileAtomic(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8') + return merged } function resolveWebDist() { const override = process.env.HERMES_DESKTOP_WEB_DIST - if (override && directoryExists(path.resolve(override))) return path.resolve(override) + + if (override && directoryExists(path.resolve(override))) {return path.resolve(override)} const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist') - if (directoryExists(unpackedDist)) return unpackedDist + + if (directoryExists(unpackedDist)) {return unpackedDist} // Final fallback: APP_ROOT/dist. When packaged with asar:true this lives // INSIDE app.asar — not a servable filesystem directory — so the embedded @@ -2801,6 +2988,7 @@ function resolveWebDist() { // unpackedDist above resolves). If we still land here while packaged, log it // so the cause isn't silent. const fallback = path.join(APP_ROOT, 'dist') + if (IS_PACKAGED && /app\.asar(?=$|[\\/])/.test(fallback) && !directoryExists(fallback)) { rememberLog( `[web-dist] dashboard frontend dir resolved to an asar-internal path that ` + @@ -2808,13 +2996,15 @@ function resolveWebDist() { `Ensure dist/** is unpacked (asarUnpack) or set HERMES_DESKTOP_WEB_DIST.` ) } + return fallback } function resolveRendererIndex() { const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')] const found = candidates.find(fileExists) - if (found) return found + + if (found) {return found} // Nothing on disk. A packaged build with no renderer bundle blank-pages with // a bare ERR_FILE_NOT_FOUND and no clue why (see #39484). Surface the cause // and the fix before Electron loads the missing file. @@ -2823,6 +3013,7 @@ function resolveRendererIndex() { `renderer bundle. Tried: ${candidates.join(', ')}. ` + `Rebuild with: hermes desktop --force-build` ) + return candidates[0] } @@ -2859,14 +3050,14 @@ function resolveHermesCwd() { ] for (const candidate of candidates) { - if (!candidate) continue + if (!candidate) {continue} const resolved = path.resolve(String(candidate)) if (isPackagedInstallPath(resolved)) { continue } - if (directoryExists(resolved)) return resolved + if (directoryExists(resolved)) {return resolved} } return app.getPath('home') @@ -2934,9 +3125,10 @@ function writeDefaultProjectDir(dir) { } } -function createPythonBackend(root, label, backendArgs, options = {}) { +function createPythonBackend(root, label, backendArgs, options: any = {}) { const python = findPythonForRoot(root) - if (!python) return null + + if (!python) {return null} const venvRoot = path.join(root, 'venv') const venvPython = getVenvPython(venvRoot) @@ -2986,9 +3178,11 @@ function resolveHermesBackend(backendArgs) { // 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer // checkout. Honour it as-is (no bootstrap; the user is driving). const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT) + if (overrideRoot && isHermesSourceRoot(overrideRoot)) { const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, backendArgs) - if (backend) return backend + + if (backend) {return backend} } // 2. Development source -- when running `npm run dev` from a checkout, the @@ -2997,7 +3191,8 @@ function resolveHermesBackend(backendArgs) { // (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.) if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) { const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, backendArgs) - if (backend) return backend + + if (backend) {return backend} } // 3. Bootstrap-complete ACTIVE_HERMES_ROOT -- the canonical install at @@ -3021,6 +3216,7 @@ function resolveHermesBackend(backendArgs) { if (hermesOverride) { const resolvedOverride = findOnPath(hermesOverride) + if (resolvedOverride) { hermesCommand = resolvedOverride } else if (!isWindowsBinaryPathInWsl(hermesOverride, { isWsl: IS_WSL })) { @@ -3041,6 +3237,7 @@ function resolveHermesBackend(backendArgs) { if (hermesCommand) { const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) + if (unwrapped) { return unwrapped } @@ -3050,9 +3247,10 @@ function resolveHermesBackend(backendArgs) { // entry-point pointing at a deleted interpreter) still resolves // via findOnPath but explodes on spawn -- the user then sees a // dead backend instead of the first-launch installer. The cheap - // `--version` probe (see backend-probes.cjs) catches that case + // `--version` probe (see backend-probes.ts) catches that case // and lets the resolver fall through to step 6 / bootstrap. const shellForProbe = isCommandScript(hermesCommand) + if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) { return ( unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) || { @@ -3066,6 +3264,7 @@ function resolveHermesBackend(backendArgs) { } ) } + rememberLog( `Ignoring existing Hermes CLI at ${hermesCommand}: --version probe failed; falling through to bootstrap.` ) @@ -3076,6 +3275,7 @@ function resolveHermesBackend(backendArgs) { // Same rationale as #4 -- the user installed this; we use it but don't // take ownership. const python = findSystemPython() + if (python) { // Same smoke-test rationale as step 4: a system Python in the // SUPPORTED_VERSIONS range can be registered (PEP 514) without @@ -3096,6 +3296,7 @@ function resolveHermesBackend(backendArgs) { shell: false } } + rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`) } @@ -3128,6 +3329,7 @@ function resolveHermesBackend(backendArgs) { async function ensureRuntime(backend) { if (!backend.bootstrap) { await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32) + return backend } @@ -3144,9 +3346,10 @@ async function ensureRuntime(backend) { rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap') if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) { - const handoffError = new Error( + const handoffError: Error & {isBootstrapFailure?: boolean, bootstrapHandedOff?: boolean} = new Error( 'Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.' ) + handoffError.isBootstrapFailure = true handoffError.bootstrapHandedOff = true bootstrapFailure = handoffError @@ -3188,6 +3391,7 @@ async function ensureRuntime(backend) { } catch { void 0 } + try { broadcastBootstrapEvent(ev) } catch { @@ -3200,7 +3404,7 @@ async function ensureRuntime(backend) { bootstrapAbortController = null if (bootstrapResult.cancelled) { - const cancelledError = new Error('Hermes install was cancelled.') + const cancelledError = new Error('Hermes install was cancelled.') as any cancelledError.isBootstrapFailure = true cancelledError.bootstrapCancelled = true bootstrapFailure = cancelledError @@ -3212,7 +3416,8 @@ async function ensureRuntime(backend) { `Hermes bootstrap failed${bootstrapResult.failedStage ? ` at stage '${bootstrapResult.failedStage}'` : ''}: ` + `${bootstrapResult.error || 'unknown error'}. ` + `Check ${path.join(HERMES_HOME, 'logs', 'desktop.log')} for the full transcript.` - ) + ) as any + bootstrapError.isBootstrapFailure = true bootstrapError.failedStage = bootstrapResult.failedStage || null // Latch the failure so subsequent startHermes() calls return this @@ -3223,6 +3428,7 @@ async function ensureRuntime(backend) { } rememberLog('[bootstrap] bootstrap complete; marker written. Re-resolving backend.') + // Re-resolve now that the install exists. The new resolution lands in // step 3 (bootstrap-complete marker) and we recurse to wire venvPython. return ensureRuntime(resolveHermesBackend(backend.args)) @@ -3257,6 +3463,7 @@ async function ensureRuntime(backend) { } const venvPython = getVenvPython(VENV_ROOT) + if (!fileExists(venvPython)) { // No venv at the expected location AND no bootstrap-needed sentinel // means we have a half-installed checkout: .git exists, source files @@ -3279,10 +3486,11 @@ async function ensureRuntime(backend) { running: true, error: null }) + return backend } -function fetchJson(url, token, options = {}) { +function fetchJson(url, token, options: any = {}) { return new Promise((resolve, reject) => { const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) const parsed = new URL(url) @@ -3291,6 +3499,7 @@ function fetchJson(url, token, options = {}) { if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) + return } @@ -3310,20 +3519,26 @@ function fetchJson(url, token, options = {}) { res.on('data', chunk => chunks.push(chunk)) res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8') + if ((res.statusCode || 500) >= 400) { reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) + return } + if (!text) { resolve(null) + return } + // A 2xx response whose body is HTML means the request fell through // to the SPA index.html (e.g. an unregistered /api path). JSON.parse // would throw an opaque `Unexpected token '<'` here, so surface a // clear diagnostic with the offending URL instead. const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) const contentType = String(res.headers['content-type'] || '') + if (looksHtml || contentType.includes('text/html')) { reject( new Error( @@ -3331,8 +3546,10 @@ function fetchJson(url, token, options = {}) { 'The endpoint is likely missing on the Hermes backend.' ) ) + return } + try { resolve(JSON.parse(text)) } catch { @@ -3346,12 +3563,13 @@ function fetchJson(url, token, options = {}) { req.setTimeout(timeoutMs, () => { req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) - if (body) req.write(body) + + if (body) {req.write(body)} req.end() }) } -function fetchPublicJson(url, options = {}) { +function fetchPublicJson(url, options: any = {}) { // Credential-free JSON GET/POST for public gateway endpoints // (``/api/status``, ``/api/auth/providers``). Unlike ``fetchJson`` it sends // NO ``X-Hermes-Session-Token`` header — used by the auth-mode probe before @@ -3360,17 +3578,21 @@ function fetchPublicJson(url, options = {}) { return new Promise((resolve, reject) => { const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) let parsed + try { parsed = new URL(url) } catch (error) { reject(new Error(`Invalid URL: ${error.message}`)) + return } + const client = parsed.protocol === 'https:' ? https : http const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) + return } @@ -3388,16 +3610,22 @@ function fetchPublicJson(url, options = {}) { res.on('data', chunk => chunks.push(chunk)) res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8') + if ((res.statusCode || 500) >= 400) { reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) + return } + if (!text) { resolve(null) + return } + const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) const contentType = String(res.headers['content-type'] || '') + if (looksHtml || contentType.includes('text/html')) { reject( new Error( @@ -3405,8 +3633,10 @@ function fetchPublicJson(url, options = {}) { 'The endpoint is likely missing on the Hermes backend.' ) ) + return } + try { resolve(JSON.parse(text)) } catch { @@ -3420,7 +3650,8 @@ function fetchPublicJson(url, options = {}) { req.setTimeout(timeoutMs, () => { req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) - if (body) req.write(body) + + if (body) {req.write(body)} req.end() }) } @@ -3436,12 +3667,19 @@ function extensionForMimeType(mimeType) { .split(';')[0] .trim() .toLowerCase() - if (type === 'image/png') return '.png' - if (type === 'image/jpeg') return '.jpg' - if (type === 'image/gif') return '.gif' - if (type === 'image/webp') return '.webp' - if (type === 'image/bmp') return '.bmp' - if (type === 'image/svg+xml') return '.svg' + + if (type === 'image/png') {return '.png'} + + if (type === 'image/jpeg') {return '.jpg'} + + if (type === 'image/gif') {return '.gif'} + + if (type === 'image/webp') {return '.webp'} + + if (type === 'image/bmp') {return '.bmp'} + + if (type === 'image/svg+xml') {return '.svg'} + return '' } @@ -3449,6 +3687,7 @@ function filenameFromUrl(rawUrl, fallback = 'image') { try { const parsed = new URL(rawUrl) const base = path.basename(decodeURIComponent(parsed.pathname || '')) + return base && base.includes('.') ? base : fallback } catch { return fallback @@ -3462,12 +3701,15 @@ const TITLE_CACHE_LIMIT = 500 const TITLE_BYTE_BUDGET = 96 * 1024 const TITLE_TIMEOUT_MS = 5000 const TITLE_MAX_REDIRECTS = 3 + // Browser-shaped UA — many bot-walled sites (GetYourGuide, Cloudflare-protected // pages) refuse anything that doesn't look like a real Chrome. const TITLE_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36' + const TITLE_ERROR_RE = /\b(access denied|attention required|captcha|error|forbidden|just a moment|request blocked|too many requests)\b/i + const HTML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'" } // Tier-2 renderer fallback config. Only invoked when curl came back empty or @@ -3475,6 +3717,7 @@ const HTML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: const RENDER_TITLE_MAX_CONCURRENT = 2 const RENDER_TITLE_TIMEOUT_MS = 8000 const RENDER_TITLE_GRACE_MS = 700 + // Resource types we cancel before the network even fires — keeps the hidden // renderer fast and cuts third-party tracking noise. const RENDER_TITLE_BLOCKED_RESOURCES = new Set([ @@ -3494,7 +3737,8 @@ const renderTitleQueue = [] function canonicalTitleCacheKey(rawUrl) { const value = String(rawUrl || '').trim() - if (!value) return '' + + if (!value) {return ''} try { const url = new URL(value) @@ -3508,7 +3752,7 @@ function canonicalTitleCacheKey(rawUrl) { } function cacheTitle(key, title) { - if (titleCache.size >= TITLE_CACHE_LIMIT) titleCache.delete(titleCache.keys().next().value) + if (titleCache.size >= TITLE_CACHE_LIMIT) {titleCache.delete(titleCache.keys().next().value)} titleCache.set(key, title) } @@ -3521,13 +3765,15 @@ function decodeHtmlEntities(value) { function parseHtmlTitle(html) { const raw = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] + return raw ? decodeHtmlEntities(raw).replace(/\s+/g, ' ').trim() : '' } -function fetchHtmlTitleWithCurl(rawUrl) { +function fetchHtmlTitleWithCurl(rawUrl: string): Promise<string> { return new Promise(resolve => { const url = String(rawUrl || '').trim() - if (!url) return resolve('') + + if (!url) {return resolve('')} const args = [ '--silent', @@ -3550,12 +3796,13 @@ function fetchHtmlTitleWithCurl(rawUrl) { '--raw', url ] + const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] })) const chunks = [] let bytes = 0 child.stdout.on('data', chunk => { - if (bytes >= TITLE_BYTE_BUDGET) return + if (bytes >= TITLE_BYTE_BUDGET) {return} const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) const remaining = TITLE_BYTE_BUDGET - bytes const next = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer @@ -3565,19 +3812,20 @@ function fetchHtmlTitleWithCurl(rawUrl) { child.on('error', () => resolve('')) child.on('close', () => { - if (!chunks.length) return resolve('') + if (!chunks.length) {return resolve('')} resolve(parseHtmlTitle(Buffer.concat(chunks).toString('utf8'))) }) }) } function getLinkTitleSession() { - if (linkTitleSession || !app.isReady()) return linkTitleSession + if (linkTitleSession || !app.isReady()) {return linkTitleSession} linkTitleSession = session.fromPartition('hermes:link-titles', { cache: false }) linkTitleSession.webRequest.onBeforeRequest((details, callback) => { callback({ cancel: RENDER_TITLE_BLOCKED_RESOURCES.has(details.resourceType) }) }) guardLinkTitleSession(linkTitleSession) + return linkTitleSession } @@ -3595,10 +3843,11 @@ function dequeueRenderTitle() { function runRenderTitleJob(rawUrl) { return new Promise(resolve => { - if (!app.isReady()) return resolve('') + if (!app.isReady()) {return resolve('')} const partitionSession = getLinkTitleSession() - if (!partitionSession) return resolve('') + + if (!partitionSession) {return resolve('')} let settled = false let window = null @@ -3606,16 +3855,20 @@ function runRenderTitleJob(rawUrl) { let graceTimer = null const finish = title => { - if (settled) return + if (settled) {return} settled = true - if (hardTimer) clearTimeout(hardTimer) - if (graceTimer) clearTimeout(graceTimer) + + if (hardTimer) {clearTimeout(hardTimer)} + + if (graceTimer) {clearTimeout(graceTimer)} const value = (title || '').replace(/\s+/g, ' ').trim() + try { - if (window && !window.isDestroyed()) window.destroy() + if (window && !window.isDestroyed()) {window.destroy()} } catch { // BrowserWindow may already be torn down; ignore. } + resolve(value) } @@ -3626,8 +3879,9 @@ function runRenderTitleJob(rawUrl) { } const finishWithTitle = () => finish(readLinkTitleWindowTitle(window)) + const scheduleGrace = () => { - if (graceTimer) clearTimeout(graceTimer) + if (graceTimer) {clearTimeout(graceTimer)} graceTimer = setTimeout(finishWithTitle, RENDER_TITLE_GRACE_MS) } @@ -3637,7 +3891,7 @@ function runRenderTitleJob(rawUrl) { window.webContents.on('page-title-updated', scheduleGrace) window.webContents.on('did-finish-load', scheduleGrace) window.webContents.on('did-fail-load', (_event, _code, _desc, _validatedURL, isMainFrame) => { - if (isMainFrame) finish('') + if (isMainFrame) {finish('')} }) window @@ -3649,7 +3903,7 @@ function runRenderTitleJob(rawUrl) { }) } -function fetchHtmlTitleWithRenderer(rawUrl) { +function fetchHtmlTitleWithRenderer(rawUrl: string): Promise<string> { return new Promise(resolve => { renderTitleQueue.push({ resolve, url: rawUrl }) dequeueRenderTitle() @@ -3658,14 +3912,19 @@ function fetchHtmlTitleWithRenderer(rawUrl) { // Strips known error/captcha titles (e.g. "GetYourGuide – Error", "Just a // moment...") so they don't get cached as the resolved title. -const usableTitle = value => (value && !TITLE_ERROR_RE.test(value) ? value : '') +function usableTitle (value: string): string { + return (value && !TITLE_ERROR_RE.test(value) ? value : '') +} function fetchLinkTitle(rawUrl) { const url = String(rawUrl || '').trim() const key = canonicalTitleCacheKey(url) - if (!key) return Promise.resolve('') - if (titleCache.has(key)) return Promise.resolve(titleCache.get(key)) - if (titleInflight.has(key)) return titleInflight.get(key) + + if (!key) {return Promise.resolve('')} + + if (titleCache.has(key)) {return Promise.resolve(titleCache.get(key))} + + if (titleInflight.has(key)) {return titleInflight.get(key)} const pending = fetchHtmlTitleWithCurl(url) .catch(() => '') @@ -3676,38 +3935,48 @@ function fetchLinkTitle(rawUrl) { .then(clean => { cacheTitle(key, clean) titleInflight.delete(key) + return clean }) titleInflight.set(key, pending) + return pending } async function resourceBufferFromUrl(rawUrl) { - if (!rawUrl) throw new Error('Missing URL') + if (!rawUrl) {throw new Error('Missing URL')} + if (rawUrl.startsWith('data:')) { const match = rawUrl.match(/^data:([^;,]+)?(;base64)?,(.*)$/s) - if (!match) throw new Error('Invalid data URL') + + if (!match) {throw new Error('Invalid data URL')} const mimeType = match[1] || 'application/octet-stream' const encoded = match[3] || '' const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8') + return { buffer, mimeType } } + if (/^file:/i.test(rawUrl)) { const { resolvedPath } = await resolveReadableFileForIpc(rawUrl, { purpose: 'Image file' }) const buffer = await fs.promises.readFile(resolvedPath) + return { buffer, mimeType: mimeTypeForPath(resolvedPath) } } const parsed = new URL(rawUrl) const client = parsed.protocol === 'https:' ? https : http + return new Promise((resolve, reject) => { const req = client.get(parsed, res => { if ((res.statusCode || 500) >= 400) { reject(new Error(`Failed to fetch ${rawUrl}: ${res.statusCode}`)) res.resume() + return } + const chunks = [] res.on('error', reject) res.on('data', chunk => chunks.push(chunk)) @@ -3718,26 +3987,31 @@ async function resourceBufferFromUrl(rawUrl) { }) }) }) + req.on('error', reject) }) } async function copyImageFromUrl(rawUrl) { - const { buffer } = await resourceBufferFromUrl(rawUrl) + const { buffer } = await resourceBufferFromUrl(rawUrl) as any const image = nativeImage.createFromBuffer(buffer) - if (image.isEmpty()) throw new Error('Could not read image') + + if (image.isEmpty()) {throw new Error('Could not read image')} clipboard.writeImage(image) } async function saveImageFromUrl(rawUrl) { - const { buffer, mimeType } = await resourceBufferFromUrl(rawUrl) + const { buffer, mimeType } = await resourceBufferFromUrl(rawUrl) as any const fallbackName = filenameFromUrl(rawUrl, `image${extensionForMimeType(mimeType) || '.png'}`) + const result = await dialog.showSaveDialog(mainWindow, { title: 'Save Image', defaultPath: fallbackName }) - if (result.canceled || !result.filePath) return false + + if (result.canceled || !result.filePath) {return false} await fs.promises.writeFile(result.filePath, buffer) + return true } @@ -3745,6 +4019,7 @@ async function writeComposerImage(buffer, ext = '.png') { const rawExt = String(ext || '.png') .trim() .toLowerCase() + const normalizedExt = rawExt.startsWith('.') ? rawExt : `.${rawExt}` const safeExt = /^\.[a-z0-9]{1,5}$/.test(normalizedExt) ? normalizedExt : '.png' const dir = path.join(app.getPath('userData'), 'composer-images') @@ -3753,6 +4028,7 @@ async function writeComposerImage(buffer, ext = '.png') { const random = crypto.randomBytes(3).toString('hex') const filePath = path.join(dir, `composer_${stamp}_${random}${safeExt}`) await fs.promises.writeFile(filePath, buffer) + return filePath } @@ -3777,6 +4053,7 @@ function expandUserPath(filePath) { async function previewFileTarget(rawTarget, baseDir) { const raw = String(rawTarget || '').trim() const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd() + let resolved = resolveRequestedPathForIpc(/^file:/i.test(raw) ? raw : expandUserPath(raw), { baseDir: base, purpose: 'Preview target' @@ -3787,6 +4064,7 @@ async function previewFileTarget(rawTarget, baseDir) { } const ext = path.extname(resolved).toLowerCase() + if (!fileExists(resolved)) { return null } @@ -3858,13 +4136,15 @@ async function normalizePreviewTarget(rawTarget, baseDir) { async function filePathFromPreviewUrl(rawUrl) { const { resolvedPath } = await resolveReadableFileForIpc(String(rawUrl || ''), { purpose: 'Preview file' }) + return resolvedPath } function sendPreviewFileChanged(payload) { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) {return} webContents.send('hermes:preview-file-changed', payload) } @@ -3874,6 +4154,7 @@ async function watchPreviewFile(rawUrl) { const targetName = path.basename(filePath) const id = crypto.randomBytes(12).toString('base64url') let timer = null + const watcher = fs.watch(watchDir, (_eventType, filename) => { const changedName = filename ? path.basename(String(filename)) : '' @@ -3881,17 +4162,18 @@ async function watchPreviewFile(rawUrl) { return } - if (timer) clearTimeout(timer) + if (timer) {clearTimeout(timer)} timer = setTimeout(() => { timer = null - if (!fileExists(filePath)) return + + if (!fileExists(filePath)) {return} sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() }) }, PREVIEW_WATCH_DEBOUNCE_MS) }) previewWatchers.set(id, { close: () => { - if (timer) clearTimeout(timer) + if (timer) {clearTimeout(timer)} watcher.close() } }) @@ -3925,6 +4207,7 @@ async function waitForHermes(baseUrl, token) { while (Date.now() < deadline) { try { await fetchJson(`${baseUrl}/api/status`, token) + return } catch (error) { lastError = error @@ -3936,7 +4219,8 @@ async function waitForHermes(baseUrl, token) { } function getWindowButtonPosition() { - if (!IS_MAC) return null + if (!IS_MAC) {return null} + return mainWindow?.getWindowButtonPosition?.() || WINDOW_BUTTON_POSITION } @@ -3953,16 +4237,18 @@ function getWindowState() { } function sendBackendExit(payload) { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) {return} webContents.send('hermes:backend-exit', payload) } function sendClosePreviewRequested() { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) {return} webContents.send('hermes:close-preview-requested') } @@ -3970,17 +4256,19 @@ function sendClosePreviewRequested() { // renderer's WebSocket to the local backend; the renderer reconnects on this // signal so the chat composer doesn't stay stuck on "Starting Hermes...". function sendPowerResume() { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) {return} webContents.send('hermes:power-resume') } let powerResumeRegistered = false function registerPowerResumeListeners() { - if (powerResumeRegistered) return + if (powerResumeRegistered) {return} powerResumeRegistered = true + try { // 'resume' covers sleep/wake; 'unlock-screen' covers lock/unlock without a // full suspend. Either can drop an idle socket. @@ -3997,18 +4285,21 @@ function getAppIconPath() { } function sendOpenUpdatesRequested() { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) {return} webContents.send('hermes:open-updates') - if (!mainWindow.isVisible()) mainWindow.show() + + if (!mainWindow.isVisible()) {mainWindow.show()} mainWindow.focus() } -function sendWindowStateChanged(nextIsFullscreen) { - if (!mainWindow || mainWindow.isDestroyed()) return +function sendWindowStateChanged(nextIsFullscreen?: boolean) { + if (!mainWindow || mainWindow.isDestroyed()) {return} const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) {return} const state = getWindowState() if (typeof nextIsFullscreen === 'boolean') { @@ -4020,10 +4311,12 @@ function sendWindowStateChanged(nextIsFullscreen) { function buildApplicationMenu() { const template = [] + const checkForUpdatesItem = { label: 'Check for Updates…', click: () => sendOpenUpdatesRequested() } + if (IS_MAC) { template.push({ label: APP_NAME, @@ -4130,6 +4423,7 @@ function toggleDevTools(window) { // increase versus a much better support story when WS connection or // CSP issues surface in the field. const { webContents } = window + if (webContents.isDevToolsOpened()) { webContents.closeDevTools() } else { @@ -4141,11 +4435,13 @@ function installDevToolsShortcut(window) { // F12 / Cmd+Opt+I works in both dev and packaged builds. window.webContents.on('before-input-event', (event, input) => { const key = input.key.toLowerCase() + const isInspectShortcut = input.key === 'F12' || (IS_MAC && input.meta && input.alt && key === 'i') || (!IS_MAC && input.control && input.shift && key === 'i') - if (!isInspectShortcut) return + + if (!isInspectShortcut) {return} event.preventDefault() toggleDevTools(window) }) @@ -4156,7 +4452,7 @@ function installPreviewShortcut(window) { const key = String(input.key || '').toLowerCase() const isPreviewCloseShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift - if (!isPreviewCloseShortcut || !previewShortcutActive) return + if (!isPreviewCloseShortcut || !previewShortcutActive) {return} event.preventDefault() sendClosePreviewRequested() @@ -4167,10 +4463,11 @@ function installPreviewShortcut(window) { // survives reloads/restarts) rather than a main-process JSON file. The main // process owns setZoomLevel, so we mirror each change into localStorage and // read it back on did-finish-load to re-apply after reloads or crash recovery. -const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs') +import { clampZoomLevel, percentToZoomLevel, ZOOM_STORAGE_KEY, zoomLevelToPercent } from './zoom' + function setAndPersistZoomLevel(window, zoomLevel) { - if (!window || window.isDestroyed()) return + if (!window || window.isDestroyed()) {return} const next = clampZoomLevel(zoomLevel) window.webContents.setZoomLevel(next) // Keep any open settings UI in sync, including changes made via the @@ -4184,13 +4481,13 @@ function setAndPersistZoomLevel(window, zoomLevel) { } function restorePersistedZoomLevel(window) { - if (!window || window.isDestroyed()) return + if (!window || window.isDestroyed()) {return} window.webContents .executeJavaScript( `(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()` ) .then(stored => { - if (stored == null || !window || window.isDestroyed()) return + if (stored == null || !window || window.isDestroyed()) {return} const level = clampZoomLevel(Number(stored)) window.webContents.setZoomLevel(level) }) @@ -4205,9 +4502,11 @@ function installZoomShortcuts(window) { const ZOOM_STEP = 0.1 window.webContents.on('before-input-event', (event, input) => { const mod = IS_MAC ? input.meta : input.control - if (!mod || input.alt || input.shift) return + + if (!mod || input.alt || input.shift) {return} const key = input.key + if (key === '0') { event.preventDefault() setAndPersistZoomLevel(window, 0) @@ -4260,7 +4559,7 @@ function installContextMenu(window) { } if (hasLink) { - if (template.length) template.push({ type: 'separator' }) + if (template.length) {template.push({ type: 'separator' })} template.push( { label: 'Open Link', @@ -4279,7 +4578,7 @@ function installContextMenu(window) { const suggestions = Array.isArray(params.dictionarySuggestions) ? params.dictionarySuggestions : [] if (isEditable && params.misspelledWord && suggestions.length > 0) { - if (template.length) template.push({ type: 'separator' }) + if (template.length) {template.push({ type: 'separator' })} for (const suggestion of suggestions.slice(0, 5)) { template.push({ @@ -4296,7 +4595,8 @@ function installContextMenu(window) { } if (hasSelection || isEditable) { - if (template.length) template.push({ type: 'separator' }) + if (template.length) {template.push({ type: 'separator' })} + if (isEditable) { template.push( { role: 'cut', enabled: params.editFlags.canCut }, @@ -4332,15 +4632,19 @@ function isAudioCapturePermission(permission, details) { if (permission === 'audioCapture') { return true } + if (permission !== 'media') { return false } + const mediaTypes = details?.mediaTypes + if (!Array.isArray(mediaTypes) || mediaTypes.length === 0) { // Windows: mediaTypes is often empty for a mic request. Don't deny on // missing metadata. (A video request would carry mediaTypes:['video'].) return true } + return mediaTypes.includes('audio') && !mediaTypes.includes('video') } @@ -4355,9 +4659,10 @@ function installMediaPermissions() { // the check defaults to false and the mic is denied before the request // handler ever runs. session.defaultSession.setPermissionCheckHandler((_webContents, permission, _origin, details) => { - if (permission === 'media' || permission === 'audioCapture') { + if (permission === 'media' || permission === 'audioCapture' as any /* todo: is this needed? */) { // details.mediaType is a single string here (not the mediaTypes array). const mediaType = details?.mediaType + if (mediaType === 'video') { return false } @@ -4401,27 +4706,32 @@ function installMediaPermissions() { const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth' function getOauthSession() { - if (oauthSession || !app.isReady()) return oauthSession + if (oauthSession || !app.isReady()) {return oauthSession} oauthSession = session.fromPartition(OAUTH_SESSION_PARTITION) + return oauthSession } // Bare + prefixed variants of the session cookies live in -// connection-config.cjs (cookiesHaveSession / cookiesHaveLiveSession). See +// connection-config.ts (cookiesHaveSession / cookiesHaveLiveSession). See // that module for details. async function hasOauthSessionCookie(baseUrl) { const sess = getOauthSession() - if (!sess) return false + + if (!sess) {return false} const parsed = new URL(baseUrl) + try { // Query by URL so the cookie jar applies Domain/Path/Secure scoping for us. const cookies = await sess.cookies.get({ url: baseUrl }) + return cookiesHaveSession(cookies) } catch { // Fall back to a host match if the URL query path errors. try { const cookies = await sess.cookies.get({ domain: parsed.hostname }) + return cookiesHaveSession(cookies) } catch { return false @@ -4438,14 +4748,18 @@ async function hasOauthSessionCookie(baseUrl) { // cheap early-out before attempting a network round-trip in resolveRemoteBackend. async function hasLiveOauthSession(baseUrl) { const sess = getOauthSession() - if (!sess) return false + + if (!sess) {return false} const parsed = new URL(baseUrl) + try { const cookies = await sess.cookies.get({ url: baseUrl }) + return cookiesHaveLiveSession(cookies) } catch { try { const cookies = await sess.cookies.get({ domain: parsed.hostname }) + return cookiesHaveLiveSession(cookies) } catch { return false @@ -4455,13 +4769,16 @@ async function hasLiveOauthSession(baseUrl) { async function clearOauthSession(baseUrl) { const sess = getOauthSession() - if (!sess) return + + if (!sess) {return} + try { const cookies = await sess.cookies.get(baseUrl ? { url: baseUrl } : {}) await Promise.all( cookies.map(c => { const scheme = c.secure ? 'https' : 'http' const cookieUrl = `${scheme}://${c.domain.replace(/^\./, '')}${c.path || '/'}` + return sess.cookies.remove(cookieUrl, c.name).catch(() => undefined) }) ) @@ -4479,11 +4796,15 @@ function openOauthLoginWindow(baseUrl) { return new Promise((resolve, reject) => { if (!app.isReady()) { reject(new Error('Desktop is not ready to start an OAuth login.')) + return } + const sess = getOauthSession() + if (!sess) { reject(new Error('OAuth session partition is unavailable.')) + return } @@ -4492,21 +4813,25 @@ function openOauthLoginWindow(baseUrl) { let pollTimer = null const finish = err => { - if (settled) return + if (settled) {return} settled = true - if (pollTimer) clearInterval(pollTimer) + + if (pollTimer) {clearInterval(pollTimer)} + try { - if (win && !win.isDestroyed()) win.destroy() + if (win && !win.isDestroyed()) {win.destroy()} } catch { // window already torn down } - if (err) reject(err) - else resolve({ baseUrl, ok: true }) + + if (err) {reject(err)} + else {resolve({ baseUrl, ok: true })} } const checkCookie = async () => { - if (settled) return - if (await hasOauthSessionCookie(baseUrl)) finish(null) + if (settled) {return} + + if (await hasOauthSessionCookie(baseUrl)) {finish(null)} } try { @@ -4525,6 +4850,7 @@ function openOauthLoginWindow(baseUrl) { }) } catch (error) { finish(error instanceof Error ? error : new Error(String(error))) + return } @@ -4537,7 +4863,7 @@ function openOauthLoginWindow(baseUrl) { pollTimer = setInterval(() => void checkCookie(), 750) win.on('closed', () => { - if (!settled) finish(new Error('Login window closed before authentication completed.')) + if (!settled) {finish(new Error('Login window closed before authentication completed.'))} }) // ``next`` is intentionally omitted: the gateway lands on ``/`` after @@ -4553,24 +4879,32 @@ function openOauthLoginWindow(baseUrl) { // JSON request routed through the OAuth session partition so the HttpOnly // session cookie is attached automatically by Electron's net stack. Used for // authed REST against a gated gateway, including minting WS tickets. -function fetchJsonViaOauthSession(url, options = {}) { +function fetchJsonViaOauthSession(url, options: any = {}) { return new Promise((resolve, reject) => { const sess = getOauthSession() + if (!sess) { reject(new Error('OAuth session partition is unavailable.')) + return } + let parsed + try { parsed = new URL(url) } catch (error) { reject(new Error(`Invalid URL: ${error.message}`)) + return } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) + return } + const body = serializeJsonBody(options.body) const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) @@ -4580,17 +4914,21 @@ function fetchJsonViaOauthSession(url, options = {}) { session: sess, useSessionCookies: true, redirect: 'follow' - }) + } as any) + setJsonRequestHeaders(request) let timedOut = false + const timer = setTimeout(() => { timedOut = true + try { request.abort() } catch { // already finished } + reject(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }, timeoutMs) @@ -4598,26 +4936,34 @@ function fetchJsonViaOauthSession(url, options = {}) { const chunks = [] res.on('data', chunk => chunks.push(Buffer.from(chunk))) res.on('end', () => { - if (timedOut) return + if (timedOut) {return} clearTimeout(timer) const text = Buffer.concat(chunks).toString('utf8') const statusCode = res.statusCode || 500 + if (statusCode >= 400) { - const err = new Error(`${statusCode}: ${text || ''}`) + const err = new Error(`${statusCode}: ${text || ''}`) as any err.statusCode = statusCode reject(err) + return } + if (!text) { resolve(null) + return } + const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) const contentType = String(res.headers['content-type'] || res.headers['Content-Type'] || '') + if (looksHtml || contentType.includes('text/html')) { reject(new Error(`Expected JSON from ${url} but got HTML (status ${statusCode}).`)) + return } + try { resolve(JSON.parse(text)) } catch { @@ -4626,11 +4972,12 @@ function fetchJsonViaOauthSession(url, options = {}) { }) }) request.on('error', error => { - if (timedOut) return + if (timedOut) {return} clearTimeout(timer) reject(error) }) - if (body) request.write(body) + + if (body) {request.write(body)} request.end() }) } @@ -4642,11 +4989,14 @@ async function mintGatewayWsTicket(baseUrl) { const body = await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, { method: 'POST', timeoutMs: 8_000 - }) + }) as any + const ticket = body?.ticket + if (!ticket || typeof ticket !== 'string') { throw new Error('Gateway did not return a WS ticket.') } + return ticket } @@ -4664,10 +5014,13 @@ async function freshGatewayWsUrl(profile) { // the wrong profile's DB. A null/empty profile resolves to the primary, so // legacy callers and single-profile users are unchanged. const connection = await ensureBackend(profile) + if (connection.authMode === 'oauth') { const ticket = await mintGatewayWsTicket(connection.baseUrl) + return buildGatewayWsUrlWithTicket(connection.baseUrl, ticket) } + // Local/token: the cached wsUrl already carries the (long-lived) token. return connection.wsUrl } @@ -4701,29 +5054,35 @@ function decryptDesktopSecret(secret) { // Validate + normalize the per-profile remote overrides map read from disk. // Drops malformed names/entries and keeps only the recognized fields so a // hand-edited or stale connection.json can't inject junk into resolution. -function sanitizeConnectionProfiles(raw) { +function sanitizeConnectionProfiles(raw: Record<string, any>) { if (!raw || typeof raw !== 'object') { return {} } const out = {} + for (const [name, entry] of Object.entries(raw)) { if (!entry || typeof entry !== 'object') { continue } + if (name !== 'default' && !PROFILE_NAME_RE.test(name)) { continue } - const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' } + const cleaned: {mode: 'remote' | 'local', url?: string, authMode?: string, token?: object } ={ mode: entry.mode === 'remote' ? 'remote' : 'local', } const url = String(entry.url || '').trim() + if (url) { cleaned.url = url } + cleaned.authMode = normAuthMode(entry.authMode) - if (entry.token && typeof entry.token === 'object') { + + if ((entry as any).token && typeof entry.token === 'object') { cleaned.token = entry.token } + out[name] = cleaned } @@ -4735,6 +5094,7 @@ function readDesktopConnectionConfig() { // process or an external tool). Our own writes update the cache inline // via writeDesktopConnectionConfig, but external changes would be missed. let mtime = null + try { mtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs } catch { @@ -4832,6 +5192,7 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local' let remoteOauthConnected = false + if (authMode === 'oauth' && remoteUrl) { try { // Display signal: treat a live RT cookie as "connected" even if the AT @@ -4866,10 +5227,11 @@ function buildRemoteBlock(remoteUrl, authMode, token) { if (authMode !== 'oauth' && !decryptDesktopSecret(token)) { throw new Error('Remote gateway session token is required.') } + return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token } } -function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) { +function coerceDesktopConnectionConfig(input :any= {}, existing = readDesktopConnectionConfig(), options: any = {}) { const persistToken = options.persistToken !== false const key = connectionScopeKey(input.profile) const mode = input.mode === 'remote' ? 'remote' : 'local' @@ -4880,6 +5242,7 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect // authMode: explicit input wins; otherwise inherit the saved value, default 'token'. const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode) const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : '' + const nextToken = incomingToken ? persistToken ? encryptDesktopSecret(incomingToken) @@ -4890,11 +5253,13 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect // Per-profile scope: a remote entry pins this profile to its own backend; a // local entry clears the override so the profile inherits the default. const profiles = { ...(existing.profiles || {}) } + if (mode === 'remote') { profiles[key] = { mode: 'remote', ...buildRemoteBlock(remoteUrl, authMode, nextToken) } } else { delete profiles[key] } + return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles } } @@ -4928,18 +5293,21 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) { const err = new Error( 'Remote Hermes gateway uses OAuth, but you are not signed in. ' + 'Open Settings → Gateway and click "Sign in", or switch back to Local.' - ) + ) as any + err.needsOauthLogin = true throw err } let ticket + try { ticket = await mintGatewayWsTicket(baseUrl) } catch (error) { const err = new Error( 'Your remote gateway session has expired. ' + 'Open Settings → Gateway and click "Sign in" again.' - ) + ) as any + err.needsOauthLogin = true err.cause = error throw err @@ -4987,14 +5355,17 @@ async function resolveRemoteBackend(profile) { // over the env override so an explicitly-configured profile always // reaches its intended backend. const override = profileRemoteOverride(config, profile) + if (override) { const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token) + return buildRemoteConnection(override.url, override.authMode, token, 'profile') } // 2. Env override (global, token-auth only). const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN + if (rawEnvUrl) { if (!rawEnvToken) { throw new Error( @@ -5002,6 +5373,7 @@ async function resolveRemoteBackend(profile) { 'Both must be provided to connect to a remote Hermes backend.' ) } + return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env') } @@ -5009,8 +5381,10 @@ async function resolveRemoteBackend(profile) { if (config.mode !== 'remote') { return null } + const authMode = normAuthMode(config.remote?.authMode) const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token) + return buildRemoteConnection(config.remote?.url, authMode, token, 'settings') } @@ -5024,6 +5398,7 @@ function profileHasRemoteOverride(profile) { function configuredRemoteProfileNames() { const config = readDesktopConnectionConfig() + return Object.keys(config.profiles || {}).filter(name => profileRemoteOverride(config, name)) } @@ -5034,6 +5409,7 @@ function globalRemoteActive() { if (process.env.HERMES_DESKTOP_REMOTE_URL) { return true } + return readDesktopConnectionConfig().mode === 'remote' } @@ -5043,10 +5419,11 @@ async function fetchJsonForProfile(profile, path) { } // Issue an arbitrary method against a profile's resolved backend, parsed JSON. -async function requestJsonForProfile(profile, path, method, body) { +async function requestJsonForProfile(profile: string, path: string, method: Method, body?: string) { const conn = await ensureBackend(profile) const url = `${conn.baseUrl}${path}` const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS } + return conn.authMode === 'oauth' ? fetchJsonViaOauthSession(url, opts) : fetchJson(url, conn.token, opts) } @@ -5066,9 +5443,10 @@ async function probeRemoteAuthMode(rawUrl) { const baseUrl = normalizeRemoteBaseUrl(rawUrl) let status + try { status = await fetchPublicJson(`${baseUrl}/api/status`, { timeoutMs: 8_000 }) - } catch (error) { + } catch (error: any) { return { baseUrl, reachable: false, @@ -5089,7 +5467,8 @@ async function probeRemoteAuthMode(rawUrl) { // an OAuth-redirect one (``supports_password``). A failure here doesn't // change the auth mode, so swallow it. try { - const body = await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 }) + const body = await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 }) as any + if (Array.isArray(body?.providers)) { providers = body.providers .filter(p => p && typeof p === 'object') @@ -5115,14 +5494,16 @@ async function probeRemoteAuthMode(rawUrl) { } } -async function testDesktopConnectionConfig(input = {}) { +async function testDesktopConnectionConfig(input: any = {}) { const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false }) const key = connectionScopeKey(input.profile) // The block under test: a per-profile entry or the global remote. Coerce has // already normalized the URL and resolved token inheritance for the scope. const block = key ? config.profiles?.[key] || null : config.remote + const wantRemote = block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block) + // ``/api/status`` is public on every gateway (no creds needed), so a // reachability test works for local, token, and oauth modes alike — we only // need a base URL. For a remote config we normalize the URL from the input; @@ -5130,9 +5511,11 @@ async function testDesktopConnectionConfig(input = {}) { let baseUrl let token = null let authMode = 'token' + if (wantRemote && block?.url) { baseUrl = normalizeRemoteBaseUrl(block.url) authMode = normAuthMode(block.authMode) + if (authMode !== 'oauth') { token = decryptDesktopSecret(block.token) } @@ -5142,7 +5525,8 @@ async function testDesktopConnectionConfig(input = {}) { token = remote.token authMode = normAuthMode(remote.authMode) } - const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 }) + + const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 }) as any // The HTTP status check above proves the backend is reachable, but the chat // surface only works once the renderer's live WebSocket to ``/api/ws`` @@ -5152,11 +5536,13 @@ async function testDesktopConnectionConfig(input = {}) { // connect to Hermes gateway". Mirror the renderer's connect here so the test // reflects the full path the app actually uses. const wsUrl = await resolveTestWsUrl(baseUrl, authMode, token, { mintTicket: mintGatewayWsTicket }) + // Skip the WS leg only when the runtime genuinely lacks a WebSocket (so an // older Electron/Node never fails the test spuriously); Electron's main // process ships a global WebSocket on every supported version. if (wsUrl && typeof globalThis.WebSocket === 'function') { const probe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket }) + if (!probe.ok) { throw new Error( `Reached the gateway over HTTP, but the live WebSocket (/api/ws) connection failed: ${probe.reason} ` + @@ -5186,7 +5572,8 @@ function resetBootProgressForReconnect() { } function stopBackendChild(child) { - if (!child || child.killed) return + if (!child || child.killed) {return} + try { if (IS_WINDOWS && Number.isInteger(child.pid)) { forceKillProcessTree(child.pid) @@ -5224,11 +5611,12 @@ async function waitForBackendExit(child, timeoutMs = 5000) { if (!child) { return } + if (child.exitCode !== null || child.signalCode !== null) { return } - await new Promise(resolve => { + await new Promise<void>(resolve => { const timer = setTimeout(() => { try { if (IS_WINDOWS && Number.isInteger(child.pid)) { @@ -5239,8 +5627,10 @@ async function waitForBackendExit(child, timeoutMs = 5000) { } catch { // Already gone. } + resolve() }, timeoutMs) + child.once('exit', () => { clearTimeout(timer) resolve() @@ -5267,8 +5657,10 @@ async function ensureBackend(profile) { } const existing = backendPool.get(key) + if (existing) { existing.lastActiveAt = Date.now() + return existing.connectionPromise } @@ -5281,6 +5673,7 @@ async function ensureBackend(profile) { }) backendPool.set(key, entry) startPoolIdleReaper() + return entry.connectionPromise } @@ -5289,9 +5682,11 @@ async function ensureBackend(profile) { // streaming, since the main process can't see the direct renderer↔backend WS. function touchPoolBackend(profile) { const key = profile && String(profile).trim() ? String(profile).trim() : null - if (!key) return + + if (!key) {return} const entry = backendPool.get(key) - if (entry) entry.lastActiveAt = Date.now() + + if (entry) {entry.lastActiveAt = Date.now()} } // Evict least-recently-used pool backends until at most `keep` remain — but only @@ -5299,14 +5694,17 @@ function touchPoolBackend(profile) { // window). When every backend is actively kept alive we let the pool exceed the // soft cap rather than kill a running session. function evictLruPoolBackends(keep) { - if (backendPool.size <= keep) return + if (backendPool.size <= keep) {return} const now = Date.now() + const evictable = [...backendPool.entries()] .filter(([, entry]) => now - (entry.lastActiveAt || 0) > POOL_KEEPALIVE_FRESH_MS) .sort((a, b) => (a[1].lastActiveAt || 0) - (b[1].lastActiveAt || 0)) + let removable = backendPool.size - Math.max(0, keep) + for (const [profile] of evictable) { - if (removable <= 0) break + if (removable <= 0) {break} rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${POOL_MAX_BACKENDS})`) stopPoolBackend(profile) removable -= 1 @@ -5314,21 +5712,24 @@ function evictLruPoolBackends(keep) { } function startPoolIdleReaper() { - if (poolIdleReaper) return + if (poolIdleReaper) {return} poolIdleReaper = setInterval(() => { const now = Date.now() + for (const [profile, entry] of [...backendPool.entries()]) { if (now - (entry.lastActiveAt || 0) > POOL_IDLE_MS) { rememberLog(`Reaping idle profile backend "${profile}" (idle > ${Math.round(POOL_IDLE_MS / 1000)}s)`) stopPoolBackend(profile) } } + if (backendPool.size === 0 && poolIdleReaper) { clearInterval(poolIdleReaper) poolIdleReaper = null } }, 60_000) - if (typeof poolIdleReaper.unref === 'function') poolIdleReaper.unref() + + if (typeof poolIdleReaper.unref === 'function') {poolIdleReaper.unref()} } // Spawn an additional dashboard backend pinned to a named profile. Mirrors the @@ -5342,8 +5743,10 @@ async function spawnPoolBackend(profile, entry) { // entry keeps `entry.process === null`, which stopPoolBackend/evict already // tolerate. const remote = await resolveRemoteBackend(profile) + if (remote) { await waitForHermes(remote.baseUrl, remote.token) + return { ...remote, profile, @@ -5390,6 +5793,7 @@ async function spawnPoolBackend(profile, entry) { stdio: ['ignore', 'pipe', 'pipe'] }) ) + entry.process = child entry.token = token @@ -5398,9 +5802,11 @@ async function spawnPoolBackend(profile, entry) { let ready = false let rejectStart = null + const startFailed = new Promise((_resolve, reject) => { rejectStart = reject }) + child.once('error', error => { rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`) backendPool.delete(profile) @@ -5409,6 +5815,7 @@ async function spawnPoolBackend(profile, entry) { child.once('exit', (code, signal) => { rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`) backendPool.delete(profile) + if (!ready) { rejectStart?.( new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`) @@ -5418,19 +5825,23 @@ async function spawnPoolBackend(profile, entry) { // Discover the ephemeral port the child bound to const port = await Promise.race([waitForDashboardPortAnnouncement(child, { readyFile }), startFailed]) + if (readyFile) { fs.unlink(readyFile, () => {}) } + entry.port = port const baseUrl = `http://127.0.0.1:${port}` await Promise.race([waitForHermes(baseUrl, token), startFailed]) ready = true + const authToken = await adoptServedDashboardToken(baseUrl, token, { childAlive: () => child.exitCode === null && !child.killed, label: `Hermes backend for profile "${profile}"`, rememberLog }) + entry.token = authToken return { @@ -5448,14 +5859,16 @@ async function spawnPoolBackend(profile, entry) { function stopPoolBackend(profile) { const entry = backendPool.get(profile) - if (!entry) return + + if (!entry) {return} backendPool.delete(profile) stopBackendChild(entry.process) } async function teardownPoolBackendAndWait(profile) { const entry = backendPool.get(profile) - if (!entry) return + + if (!entry) {return} backendPool.delete(profile) stopBackendChild(entry.process) @@ -5475,11 +5888,13 @@ function profileNameFromDeleteRequest(request) { } const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/) + if (!match) { return null } let raw = '' + try { raw = decodeURIComponent(match[1]) } catch { @@ -5487,12 +5902,15 @@ function profileNameFromDeleteRequest(request) { } const name = raw.trim() + if (!name) { return null } + if (name.toLowerCase() === 'default') { return 'default' } + return name.toLowerCase() } @@ -5502,6 +5920,7 @@ function profileNameFromDeleteRequest(request) { // backend whose ensure_hermes_home() recreates the deleted profile directory. async function prepareProfileDeleteRequest(request) { const profile = profileNameFromDeleteRequest(request) + if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) { return null } @@ -5509,10 +5928,12 @@ async function prepareProfileDeleteRequest(request) { if (profile === primaryProfileKey()) { writeActiveDesktopProfile('default') await teardownPrimaryBackendAndWait() + return profile } await teardownPoolBackendAndWait(profile) + return profile } @@ -5526,16 +5947,19 @@ async function startHermes() { if (bootstrapFailure) { throw bootstrapFailure } + if (backendStartFailure) { throw backendStartFailure } - if (connectionPromise) return connectionPromise + + if (connectionPromise) {return connectionPromise} connectionPromise = (async () => { await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8) // Resolve for the desktop's primary profile so a per-profile remote // override on the active profile is honored (falls back to env / global). const remote = await resolveRemoteBackend(primaryProfileKey()) + if (remote) { await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24) await waitForHermes(remote.baseUrl, remote.token) @@ -5546,6 +5970,7 @@ async function startHermes() { running: true, error: null }) + return { baseUrl: remote.baseUrl, mode: 'remote', @@ -5575,9 +6000,11 @@ async function startHermes() { // unset preference keeps the legacy launch so existing installs are // unaffected. const activeProfile = readActiveDesktopProfile() + if (activeProfile) { backendArgs.unshift('--profile', activeProfile) } + await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28) const backend = await ensureRuntime(resolveHermesBackend(backendArgs)) // Route old runtimes (no `serve`) through the legacy `dashboard --no-open`. @@ -5623,9 +6050,11 @@ async function startHermes() { hermesProcess.stderr.on('data', rememberLog) let backendReady = false let rejectBackendStart = null + const backendStartFailed = new Promise((_resolve, reject) => { rejectBackendStart = reject }) + hermesProcess.once('error', error => { rememberLog(`Hermes backend failed to start: ${error.message}`) updateBootProgress( @@ -5647,6 +6076,7 @@ async function startHermes() { hermesProcess = null connectionPromise = null sendBackendExit({ code, signal }) + if (!backendReady) { const message = `Hermes backend exited before it became ready (${signal || code}).` updateBootProgress( @@ -5667,11 +6097,13 @@ async function startHermes() { }) await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86) + // Discover the ephemeral port the child bound to const port = await Promise.race([ waitForDashboardPortAnnouncement(hermesProcess, { readyFile }), backendStartFailed ]) + if (readyFile) { fs.unlink(readyFile, () => {}) } @@ -5681,11 +6113,13 @@ async function startHermes() { await Promise.race([waitForHermes(baseUrl, token), backendStartFailed]) backendReady = true backendStartFailure = null + const authToken = await adoptServedDashboardToken(baseUrl, token, { // The exit/error handlers null hermesProcess when the child dies. childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed, rememberLog }) + updateBootProgress({ phase: 'backend.ready', message: 'Hermes backend is ready. Finalizing desktop startup', @@ -5753,18 +6187,21 @@ function wireCommonWindowHandlers(win) { // work with multiple chats side by side. The registry guarantees one window // per sessionId (re-opening focuses the existing window) and self-cleans on // close. The primary mainWindow is never tracked here. Pure logic + the URL -// builder live in session-windows.cjs so they stay unit-testable. +// builder live in session-windows.ts so they stay unit-testable. const sessionWindows = createSessionWindowRegistry() function focusWindow(win) { - if (!win || win.isDestroyed()) return - if (win.isMinimized()) win.restore() - if (!win.isVisible()) win.show() + if (!win || win.isDestroyed()) {return} + + if (win.isMinimized()) {win.restore()} + + if (!win.isVisible()) {win.show()} win.focus() } -function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { +function spawnSecondaryWindow({ sessionId, watch, newSession }:{ sessionId?: string, watch?: boolean, newSession?: boolean } = {}) { const icon = getAppIconPath() + const win = new BrowserWindow({ width: SESSION_WINDOW_MIN_WIDTH, height: SESSION_WINDOW_MIN_HEIGHT, @@ -5785,7 +6222,7 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { // themes/context.tsx, so the window appears already themed. show: false, backgroundColor: getWindowBackgroundColor(), - webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs')) + webPreferences: chatWindowWebPreferences(PRELOAD_PATH) }) if (IS_MAC) { @@ -5793,12 +6230,10 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { } win.once('ready-to-show', () => { - if (!win.isDestroyed()) win.show() + if (!win.isDestroyed()) {win.show()} }) - win.on('will-enter-full-screen', () => sendWindowStateChanged(true)) win.on('enter-full-screen', () => sendWindowStateChanged(true)) - win.on('will-leave-full-screen', () => sendWindowStateChanged(false)) win.on('leave-full-screen', () => sendWindowStateChanged(false)) wireCommonWindowHandlers(win) @@ -5879,7 +6314,7 @@ function spawnPetOverlayWindow(bounds) { // Fully transparent — the renderer paints only the sprite + bubble. backgroundColor: '#00000000', webPreferences: { - preload: path.join(__dirname, 'preload.cjs'), + preload: PRELOAD_PATH, contextIsolation: true, sandbox: true, nodeIntegration: false, @@ -5896,6 +6331,7 @@ function spawnPetOverlayWindow(bounds) { // switching semantics. win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver') win.setHiddenInMissionControl?.(true) + try { // Electron docs: macOS may transform process type on each // setVisibleOnAllWorkspaces() call unless skipTransformProcessType=true, @@ -5913,7 +6349,7 @@ function spawnPetOverlayWindow(bounds) { wireCommonWindowHandlers(win) win.once('ready-to-show', () => { - if (!win.isDestroyed()) win.showInactive() + if (!win.isDestroyed()) {win.showInactive()} }) win.on('closed', () => { @@ -5991,12 +6427,13 @@ function createWindow() { // Shared with the secondary session windows (chatWindowWebPreferences) so // both keep `backgroundThrottling: false` — the chat transcript streams via // a requestAnimationFrame-gated flush that Chromium pauses for blurred - // windows, stalling the live answer until refocus. See session-windows.cjs. - webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs')) + // windows, stalling the live answer until refocus. See session-windows.ts. + webPreferences: chatWindowWebPreferences(PRELOAD_PATH) }) if (IS_MAC) { mainWindow.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) + if (icon) { app.dock?.setIcon(icon) } @@ -6011,10 +6448,10 @@ function createWindow() { } } - if (savedWindowState?.isMaximized) mainWindow.maximize() + if (savedWindowState?.isMaximized) {mainWindow.maximize()} mainWindow.once('ready-to-show', () => { - if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show() + if (mainWindow && !mainWindow.isDestroyed()) {mainWindow.show()} }) mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true)) @@ -6054,7 +6491,8 @@ function createWindow() { rendererReloadTimes.push(now) setImmediate(() => { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} + try { mainWindow.webContents.reload() } catch (err) { @@ -6074,7 +6512,7 @@ function createWindow() { const details = detailsOrLevel && typeof detailsOrLevel === 'object' ? detailsOrLevel : null const level = details ? details.level : detailsOrLevel - if (level !== 3) return + if (level !== 3) {return} const text = details ? details.message : message const src = details ? details.sourceUrl : sourceId @@ -6111,6 +6549,7 @@ ipcMain.handle('hermes:connection:revalidate', async () => { } let conn = null + try { conn = await connectionPromise } catch { @@ -6124,8 +6563,10 @@ ipcMain.handle('hermes:connection:revalidate', async () => { } const base = conn.baseUrl.replace(/\/+$/, '') + try { await fetchPublicJson(`${base}/api/status`, { timeoutMs: 2_500 }) + return { ok: true, rebuilt: false } } catch { // Unreachable remote: drop the stale cache so the renderer's next reconnect @@ -6133,11 +6574,13 @@ ipcMain.handle('hermes:connection:revalidate', async () => { // nulls connectionPromise for a remote (no child to SIGTERM). rememberLog('Cached remote Hermes backend failed liveness probe; dropping stale connection.') resetHermesConnection() + return { ok: true, rebuilt: true } } }) ipcMain.handle('hermes:backend:touch', async (_event, profile) => { touchPoolBackend(profile) + return { ok: true } }) ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile)) @@ -6167,7 +6610,8 @@ ipcMain.handle('hermes:zoom:get', event => { }) ipcMain.on('hermes:zoom:set-percent', (event, percent) => { const window = BrowserWindow.fromWebContents(event.sender) - if (!window || window.isDestroyed()) return + + if (!window || window.isDestroyed()) {return} setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent))) }) @@ -6250,6 +6694,7 @@ ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => { } petOverlayWindow.setFocusable(Boolean(focusable)) + if (focusable) { petOverlayWindow.focus() } @@ -6311,6 +6756,7 @@ ipcMain.handle('hermes:bootstrap:reset', async () => { completedAt: null, unsupportedPlatform: null } + return { ok: true } }) ipcMain.handle('hermes:bootstrap:repair', async () => { @@ -6319,6 +6765,7 @@ ipcMain.handle('hermes:bootstrap:repair', async () => { // venv), and clear any latched failure + live connection. The renderer // reloads afterwards to re-drive the boot flow from scratch. rememberLog('[bootstrap] repair requested by renderer; clearing marker + latched failure') + try { if (fileExists(BOOTSTRAP_COMPLETE_MARKER)) { fs.rmSync(BOOTSTRAP_COMPLETE_MARKER, { force: true }) @@ -6326,9 +6773,11 @@ ipcMain.handle('hermes:bootstrap:repair', async () => { } catch (error) { rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`) } + bootstrapFailure = null backendStartFailure = null resetHermesConnection() + return { ok: true } }) ipcMain.handle('hermes:bootstrap:cancel', async () => { @@ -6341,8 +6790,10 @@ ipcMain.handle('hermes:bootstrap:cancel', async () => { } catch { void 0 } + return { ok: true, cancelled: true } } + return { ok: false, cancelled: false } }) ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState) @@ -6359,11 +6810,13 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => // the URL defensively so a login can be driven from a raw URL too. const baseUrl = normalizeRemoteBaseUrl(rawUrl) await openOauthLoginWindow(baseUrl) + return { ok: true, baseUrl, connected: await hasOauthSessionCookie(baseUrl) } }) ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => { const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : '' await clearOauthSession(baseUrl || undefined) + // Report against the SAME liveness notion the Settings indicator uses // (AT-or-RT) so a logout that left any session cookie behind is reflected // as still-connected rather than silently signed-out. @@ -6434,25 +6887,32 @@ async function interceptSessionRequestForRemote(request) { if (typeof request?.path !== 'string') { return undefined } + const method = (request.method || 'GET').toUpperCase() let parsed + try { parsed = new URL(request.path, 'http://x') } catch { return undefined } + const { pathname, searchParams } = parsed if (method === 'GET' && pathname === '/api/profiles/sessions') { const remoteProfiles = configuredRemoteProfileNames() + if (remoteProfiles.length === 0) { return undefined // no remote profiles → local fast path } + const requested = (searchParams.get('profile') || 'all').trim() || 'all' + if (requested !== 'all') { return profileHasRemoteOverride(requested) ? remoteSessionList(requested, searchParams) : undefined } + return mergeRemoteProfileSessions(searchParams, remoteProfiles) } @@ -6464,27 +6924,37 @@ async function interceptSessionRequestForRemote(request) { // route there and KEEP the profile param so it opens the right state.db. if (/^\/api\/sessions\/[^/]+(\/messages)?$/.test(pathname)) { const profile = (searchParams.get('profile') || request.profile || '').trim() + if (!profile) { return undefined } + if (profileHasRemoteOverride(profile)) { if (method === 'GET') { return fetchJsonForProfile(profile, pathname) } + const body = request.body && typeof request.body === 'object' ? { ...request.body } : request.body - if (body) delete body.profile + + if (body) {delete body.profile} + return requestJsonForProfile(profile, pathname, method, body) } + if (globalRemoteActive()) { // Single global backend: keep ?profile= so it opens the right state.db. const sep = pathname.includes('?') ? '&' : '?' const path = `${pathname}${sep}profile=${encodeURIComponent(profile)}` + if (method === 'GET') { return fetchJsonForProfile(null, path) } + const body = request.body && typeof request.body === 'object' ? { ...request.body, profile } : { profile } + return requestJsonForProfile(null, path, method, body) } + return undefined } @@ -6499,11 +6969,13 @@ async function remoteSessionList(profile, searchParams) { const qs = new URLSearchParams(searchParams) qs.delete('profile') // remote serves its own db; no cross-profile read there const data = await fetchJsonForProfile(profile, `/api/sessions?${qs}`) + for (const s of rowsOf(data)) { s.profile = profile s.is_default_profile = false } - return { ...data, sessions: rowsOf(data) } + + return { ...(data as any), sessions: rowsOf(data) } } // Unified list: primary's local aggregate, with each remote profile's stale local @@ -6516,10 +6988,11 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { const order = searchParams.get('order') === 'created' ? 'started_at' : 'last_active' const primary = await ensureBackend(null) + const base = await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, { method: 'GET', timeoutMs: DEFAULT_FETCH_TIMEOUT_MS - }).catch(() => ({ sessions: [], total: 0, profile_totals: {} })) + }).catch(() => ({ sessions: [], total: 0, profile_totals: {} })) as any // Over-fetch each remote from offset 0 (limit+offset rows) so the merged window // is correct for this page — mirrors the primary's per-profile over-fetch. @@ -6536,10 +7009,13 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { await Promise.all( remoteProfiles.map(async name => { const list = await remoteSessionList(name, remoteParams).catch(() => null) + if (!list) { delete profileTotals[name] // dead remote → drop its stale local total too + return } + const rows = rowsOf(list) merged.push(...rows) profileTotals[name] = Number(list.total) || rows.length @@ -6549,7 +7025,8 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { const recency = s => s?.[order] ?? s?.started_at ?? 0 merged.sort((a, b) => recency(b) - recency(a)) - return { ...base, sessions: merged.slice(offset, offset + limit), total, profile_totals: profileTotals } + + return { ...(base as any), sessions: merged.slice(offset, offset + limit), total, profile_totals: profileTotals } } ipcMain.handle('hermes:api', async (_event, request) => { @@ -6558,6 +7035,7 @@ ipcMain.handle('hermes:api', async (_event, request) => { // profile's sessions live on its remote host, so the UI's IDs 404 (or mutations // no-op) the moment they run there. Route reads + mutations to the remote. const rerouted = await interceptSessionRequestForRemote(request) + if (rerouted !== undefined) { return rerouted } @@ -6572,11 +7050,14 @@ ipcMain.handle('hermes:api', async (_event, request) => { const routeProfile = tornDownProfile ? null : profile const connection = await ensureBackend(routeProfile) const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) + const requestPath = pathWithGlobalRemoteProfile(request.path, profile, { globalRemote: globalRemoteActive(), profileRemoteOverride: profileHasRemoteOverride(profile) }) + const url = `${connection.baseUrl}${requestPath}` + // OAuth gateways authenticate REST via the HttpOnly session cookie held in // the OAuth partition — route through Electron's net stack bound to that // session so the cookie attaches automatically. Token/local modes keep using @@ -6588,6 +7069,7 @@ ipcMain.handle('hermes:api', async (_event, request) => { timeoutMs }) } + return fetchJson(url, connection.token, { method: request?.method, body: request?.body, @@ -6596,31 +7078,36 @@ ipcMain.handle('hermes:api', async (_event, request) => { }) ipcMain.handle('hermes:notify', (_event, payload) => { - if (!Notification.isSupported()) return false + if (!Notification.isSupported()) {return false} // Action buttons render only on signed macOS builds; elsewhere they're dropped // and the body click still works. const actions = Array.isArray(payload?.actions) ? payload.actions : [] + const notification = new Notification({ title: payload?.title || 'Hermes', body: payload?.body || '', silent: Boolean(payload?.silent), actions: actions.map(action => ({ type: 'button', text: String(action?.text || '') })) }) + notification.on('click', () => { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} focusWindow(mainWindow) + if (payload?.sessionId) { mainWindow.webContents.send('hermes:focus-session', payload.sessionId) } }) notification.on('action', (_actionEvent, index) => { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} const action = actions[index] + if (action?.id) { mainWindow.webContents.send('hermes:notification-action', { sessionId: payload?.sessionId, actionId: action.id }) } }) notification.show() + return true }) @@ -6629,7 +7116,9 @@ ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => { maxBytes: DATA_URL_READ_MAX_BYTES, purpose: 'File preview' }) + const data = await fs.promises.readFile(resolvedPath) + return `data:${mimeTypeForPath(resolvedPath)};base64,${data.toString('base64')}` }) @@ -6638,6 +7127,7 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => { maxBytes: TEXT_PREVIEW_SOURCE_MAX_BYTES, purpose: 'Text preview' }) + const ext = path.extname(resolvedPath).toLowerCase() const handle = await fs.promises.open(resolvedPath, 'r') const bytesToRead = Math.min(stat.size, TEXT_PREVIEW_MAX_BYTES) @@ -6660,11 +7150,13 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => { } }) -ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => { +ipcMain.handle('hermes:selectPaths', async (_event, options: any = {}) => { const properties = options?.directories ? ['openDirectory'] : ['openFile'] - if (options?.multiple !== false) properties.push('multiSelections') + + if (options?.multiple !== false) {properties.push('multiSelections')} let resolvedDefaultPath + if (options?.defaultPath) { try { resolvedDefaultPath = path.resolve(String(options.defaultPath)) @@ -6676,16 +7168,18 @@ ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => { const result = await dialog.showOpenDialog(mainWindow, { title: options?.title || 'Add context', defaultPath: resolvedDefaultPath, - properties, + properties: properties as any, filters: Array.isArray(options?.filters) ? options.filters : undefined }) - if (result.canceled) return [] + if (result.canceled) {return []} + return result.filePaths }) ipcMain.handle('hermes:writeClipboard', (_event, text) => { clipboard.writeText(String(text || '')) + return true }) @@ -6693,14 +7187,17 @@ ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(Stri ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => { const data = payload?.data - if (!data) throw new Error('saveImageBuffer: missing data') + + if (!data) {throw new Error('saveImageBuffer: missing data')} const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data) + return writeComposerImage(buffer, payload?.ext || '.png') }) ipcMain.handle('hermes:saveClipboardImage', async () => { const image = clipboard.readImage() + if (image && !image.isEmpty()) { return writeComposerImage(image.toPNG(), '.png') } @@ -6710,6 +7207,7 @@ ipcMain.handle('hermes:saveClipboardImage', async () => { // Pull it straight off the Windows clipboard via PowerShell as a fallback. if (IS_WSL) { const png = readWslWindowsClipboardImage() + if (png) { return writeComposerImage(png, '.png') } @@ -6826,10 +7324,13 @@ ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url)) ipcMain.handle('hermes:logs:reveal', async () => { try { await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true }) + if (!fileExists(DESKTOP_LOG_PATH)) { await fs.promises.appendFile(DESKTOP_LOG_PATH, '') } + shell.showItemInFolder(DESKTOP_LOG_PATH) + return { ok: true, path: DESKTOP_LOG_PATH } } catch (error) { return { ok: false, path: DESKTOP_LOG_PATH, error: error.message } @@ -6845,6 +7346,7 @@ function isExecutableFile(filePath) { try { fs.accessSync(filePath, fs.constants.X_OK) + return true } catch { return false @@ -6858,39 +7360,6 @@ function posixShellSpec(shellPath) { return { args: interactiveArgs, command: shellPath, name: shellName } } -let spawnHelperChecked = false - -// node-pty execs a `spawn-helper` binary on macOS/Linux to launch the shell in a -// fresh session. The prebuilt that ships in node-pty's `prebuilds/` (and the -// staged copy under resources/native-deps) loses its execute bit through npm -// pack / electron-builder file collection, so every nodePty.spawn() dies with -// "posix_spawnp failed". Restore +x once, lazily, before the first spawn. -function ensureSpawnHelperExecutable() { - if (spawnHelperChecked || IS_WINDOWS || !nodePtyDir) { - return - } - - spawnHelperChecked = true - - const arch = process.arch - const candidates = [ - path.join(nodePtyDir, 'build', 'Release', 'spawn-helper'), - path.join(nodePtyDir, 'prebuilds', `${process.platform}-${arch}`, 'spawn-helper') - ] - - for (const helper of candidates) { - try { - const mode = fs.statSync(helper).mode - - if ((mode & 0o111) !== 0o111) { - fs.chmodSync(helper, mode | 0o755) - } - } catch { - // Not present in this layout (e.g. compiled build vs prebuild); skip. - } - } -} - // Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box; // prefer it only after PowerShell 7+ (`pwsh`). function windowsPowerShellPath() { @@ -7180,17 +7649,12 @@ ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => { }) ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { - if (!nodePty) { - throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.') - } - - ensureSpawnHelperExecutable() - const id = crypto.randomUUID() const { args, command, name } = terminalShellCommand() const cwd = safeTerminalCwd(payload?.cwd) const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) + const ptyProcess = nodePty.spawn(command, args, { cols, cwd, @@ -7270,6 +7734,7 @@ ipcMain.handle('hermes:updates:branch:get', async () => readDesktopUpdateConfig( ipcMain.handle('hermes:updates:branch:set', async (_event, name) => { const branch = typeof name === 'string' && name.trim() ? name.trim() : DEFAULT_UPDATE_BRANCH writeDesktopUpdateConfig({ branch }) + return { branch } }) @@ -7282,9 +7747,11 @@ function resolveHermesVersion() { try { const root = resolveUpdateRoot() const initPath = path.join(root, 'hermes_cli', '__init__.py') + if (fileExists(initPath)) { const raw = fs.readFileSync(initPath, 'utf8') const match = raw.match(/__version__\s*=\s*["']([^"']+)["']/) + if (match) { return match[1] } @@ -7292,6 +7759,7 @@ function resolveHermesVersion() { } catch { // Fall through to the Electron app version below. } + return app.getVersion() } @@ -7338,6 +7806,7 @@ function uninstallVenvPython() { async function getUninstallSummary() { const py = uninstallVenvPython() const agentRoot = ACTIVE_HERMES_ROOT + // Fast JS-side fallback used when the agent venv is gone (lite client) or the // probe fails — the renderer still needs *something* to render options from. const fallback = () => ({ @@ -7359,11 +7828,13 @@ async function getUninstallSummary() { return new Promise(resolve => { let stdout = '' let settled = false + const done = value => { - if (settled) return + if (settled) {return} settled = true resolve(value) } + try { const child = spawn( py, @@ -7374,12 +7845,14 @@ async function getUninstallSummary() { stdio: ['ignore', 'pipe', 'ignore'] }) ) + child.stdout.on('data', chunk => { stdout += chunk.toString() }) child.on('error', () => done(fallback())) child.on('exit', code => { - if (code !== 0) return done(fallback()) + if (code !== 0) {return done(fallback())} + try { const line = stdout.trim().split('\n').filter(Boolean).pop() || '{}' const parsed = JSON.parse(line) @@ -7401,6 +7874,7 @@ async function getUninstallSummary() { async function runDesktopUninstall(mode) { let uninstallArgs + try { uninstallArgs = uninstallArgsForMode(mode) } catch (error) { @@ -7408,6 +7882,7 @@ async function runDesktopUninstall(mode) { } const venvPy = uninstallVenvPython() + if (!fileExists(venvPy)) { return { ok: false, @@ -7426,8 +7901,10 @@ async function runDesktopUninstall(mode) { // leave venv remnants the user can delete, which we log. let py = venvPy let pythonPath = null + if (modeRemovesAgent(mode)) { const sysPy = findSystemPython() + if (sysPy) { py = sysPy pythonPath = ACTIVE_HERMES_ROOT @@ -7468,6 +7945,7 @@ async function runDesktopUninstall(mode) { let scriptPath let runner let runnerArgs + try { if (IS_WINDOWS) { scriptPath = path.join(app.getPath('temp'), `hermes-uninstall-${Date.now()}.cmd`) @@ -7490,6 +7968,7 @@ async function runDesktopUninstall(mode) { stdio: 'ignore', windowsHide: true }) + child.unref() } catch (error) { return { ok: false, error: 'spawn-failed', message: error.message } @@ -7504,12 +7983,14 @@ async function runDesktopUninstall(mode) { // the venv python shim + app bundle unlock and the cleanup script can run. isQuittingForHandoff = true setTimeout(() => app.quit(), 800) + return { ok: true, mode, willRemoveAppBundle: Boolean(removeBundle), scriptPath } } ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary()) ipcMain.handle('hermes:uninstall:run', async (_event, payload) => { const mode = payload && typeof payload === 'object' ? payload.mode : payload + return runDesktopUninstall(String(mode || '')) }) @@ -7531,19 +8012,23 @@ let _pendingDeepLink = null let _rendererReadyForDeepLink = false function _extractDeepLink(argv) { - if (!Array.isArray(argv)) return null + if (!Array.isArray(argv)) {return null} + return argv.find(a => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null } function handleDeepLink(url) { - if (!url || typeof url !== 'string') return + if (!url || typeof url !== 'string') {return} let parsed + try { parsed = new URL(url) } catch { rememberLog(`[deeplink] ignoring malformed url: ${url}`) + return } + // hermes://blueprint/<key>?slot=val -> host="blueprint", path="/<key>" const kind = parsed.hostname || '' const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, '')) @@ -7555,10 +8040,12 @@ function handleDeepLink(url) { if (!_rendererReadyForDeepLink || !mainWindow || mainWindow.isDestroyed()) { _pendingDeepLink = payload + return } + try { - if (mainWindow.isMinimized()) mainWindow.restore() + if (mainWindow.isMinimized()) {mainWindow.restore()} mainWindow.focus() mainWindow.webContents.send('hermes:deep-link', payload) rememberLog(`[deeplink] delivered ${kind}/${name}`) @@ -7571,6 +8058,7 @@ function handleDeepLink(url) { // a link that arrived during boot/install is flushed exactly once. ipcMain.handle('hermes:deep-link-ready', () => { _rendererReadyForDeepLink = true + if (_pendingDeepLink) { const queued = _pendingDeepLink _pendingDeepLink = null @@ -7579,6 +8067,7 @@ ipcMain.handle('hermes:deep-link-ready', () => { (Object.keys(queued.params).length ? '?' + new URLSearchParams(queued.params).toString() : '') ) } + return { ok: true } }) @@ -7600,14 +8089,16 @@ function registerDeepLinkProtocol() { // second-instance argv. Without the lock a second `hermes://` launch spawns a // whole new app instead of routing into the running one. const _gotSingleInstanceLock = app.requestSingleInstanceLock() + if (!_gotSingleInstanceLock) { app.quit() } else { app.on('second-instance', (_event, argv) => { const url = _extractDeepLink(argv) - if (url) handleDeepLink(url) + + if (url) {handleDeepLink(url)} else if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore() + if (mainWindow.isMinimized()) {mainWindow.restore()} mainWindow.focus() } }) @@ -7626,6 +8117,7 @@ app.whenReady().then(() => { } else { Menu.setApplicationMenu(null) } + installMediaPermissions() registerMediaProtocol() installEmbedReferer() @@ -7637,7 +8129,8 @@ app.whenReady().then(() => { // Win/Linux cold start: the launching hermes:// URL is in our own argv. const _coldStartLink = _extractDeepLink(process.argv) - if (_coldStartLink) handleDeepLink(_coldStartLink) + + if (_coldStartLink) {handleDeepLink(_coldStartLink)} app.on('activate', () => { // Recreate the primary window if it's gone. Guard on mainWindow directly @@ -7692,6 +8185,7 @@ app.on('before-quit', () => { clearTimeout(desktopLogFlushTimer) desktopLogFlushTimer = null } + flushDesktopLogBufferSync() closePreviewWatchers() @@ -7712,5 +8206,5 @@ app.on('window-all-closed', () => { // the bundle and relaunch — without this the script's PID-wait spins to its // full timeout and the user is left with an invisible app (or an uninstall // that appears to do nothing). - if (process.platform !== 'darwin' || isQuittingForHandoff) app.quit() + if (process.platform !== 'darwin' || isQuittingForHandoff) {app.quit()} }) diff --git a/apps/desktop/electron/oauth-net-request.test.cjs b/apps/desktop/electron/oauth-net-request.test.ts similarity index 78% rename from apps/desktop/electron/oauth-net-request.test.cjs rename to apps/desktop/electron/oauth-net-request.test.ts index 63a27f6219ab..31d9e257d7e1 100644 --- a/apps/desktop/electron/oauth-net-request.test.cjs +++ b/apps/desktop/electron/oauth-net-request.test.ts @@ -1,13 +1,13 @@ /** * Tests for OAuth-session Electron net.request helpers. * - * Run with: node --test electron/oauth-net-request.test.cjs + * Run with: node --test electron/oauth-net-request.test.ts */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' +import test from 'node:test' -const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') +import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' test('serializeJsonBody returns undefined for absent bodies', () => { assert.equal(serializeJsonBody(undefined), undefined) @@ -21,6 +21,7 @@ test('serializeJsonBody JSON-encodes request bodies', () => { test('setJsonRequestHeaders does not set Electron-restricted Content-Length', () => { const headers = [] + const request = { setHeader(name, value) { headers.push([name, value]) diff --git a/apps/desktop/electron/oauth-net-request.cjs b/apps/desktop/electron/oauth-net-request.ts similarity index 87% rename from apps/desktop/electron/oauth-net-request.cjs rename to apps/desktop/electron/oauth-net-request.ts index 0498a7333faf..7c2b44821c66 100644 --- a/apps/desktop/electron/oauth-net-request.cjs +++ b/apps/desktop/electron/oauth-net-request.ts @@ -14,7 +14,5 @@ function setJsonRequestHeaders(request) { request.setHeader('Content-Type', 'application/json') } -module.exports = { - serializeJsonBody, - setJsonRequestHeaders -} +export { serializeJsonBody, + setJsonRequestHeaders } diff --git a/apps/desktop/electron/oauth-session-request.test.cjs b/apps/desktop/electron/oauth-session-request.test.ts similarity index 75% rename from apps/desktop/electron/oauth-session-request.test.cjs rename to apps/desktop/electron/oauth-session-request.test.ts index 3254318456b6..3b6f352d0da0 100644 --- a/apps/desktop/electron/oauth-session-request.test.cjs +++ b/apps/desktop/electron/oauth-session-request.test.ts @@ -6,18 +6,21 @@ * this guard is scoped to fetchJsonViaOauthSession only. */ -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' -const source = fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8') +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8') function extractFetchJsonViaOauthSession() { const start = source.indexOf('function fetchJsonViaOauthSession') const end = source.indexOf('// Mint a single-use WS ticket', start) assert.notEqual(start, -1, 'fetchJsonViaOauthSession should exist') assert.notEqual(end, -1, 'fetchJsonViaOauthSession boundary should exist') + return source.slice(start, end) } diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.ts similarity index 98% rename from apps/desktop/electron/preload.cjs rename to apps/desktop/electron/preload.ts index 0a9c4fd79212..3a74b1c3ebc2 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.ts @@ -1,4 +1,4 @@ -const { contextBridge, ipcRenderer, webUtils } = require('electron') +import { contextBridge, ipcRenderer, webUtils } from 'electron' contextBridge.exposeInMainWorld('hermesDesktop', { getConnection: profile => ipcRenderer.invoke('hermes:connection', profile), @@ -24,12 +24,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', { onState: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:pet-overlay:state', listener) + return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener) }, // Main renderer subscribes to overlay control messages. onControl: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:pet-overlay:control', listener) + return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener) } }, @@ -87,6 +89,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { onChanged: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:zoom:changed', listener) + return () => ipcRenderer.removeListener('hermes:zoom:changed', listener) } }, @@ -132,68 +135,80 @@ contextBridge.exposeInMainWorld('hermesDesktop', { const channel = `hermes:terminal:${id}:data` const listener = (_event, payload) => callback(payload) ipcRenderer.on(channel, listener) + return () => ipcRenderer.removeListener(channel, listener) }, onExit: (id, callback) => { const channel = `hermes:terminal:${id}:exit` const listener = (_event, payload) => callback(payload) ipcRenderer.on(channel, listener) + return () => ipcRenderer.removeListener(channel, listener) } }, onClosePreviewRequested: callback => { const listener = () => callback() ipcRenderer.on('hermes:close-preview-requested', listener) + return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener) }, onOpenUpdatesRequested: callback => { const listener = () => callback() ipcRenderer.on('hermes:open-updates', listener) + return () => ipcRenderer.removeListener('hermes:open-updates', listener) }, onDeepLink: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:deep-link', listener) + return () => ipcRenderer.removeListener('hermes:deep-link', listener) }, signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'), onWindowStateChanged: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:window-state-changed', listener) + return () => ipcRenderer.removeListener('hermes:window-state-changed', listener) }, onFocusSession: callback => { const listener = (_event, sessionId) => callback(sessionId) ipcRenderer.on('hermes:focus-session', listener) + return () => ipcRenderer.removeListener('hermes:focus-session', listener) }, onNotificationAction: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:notification-action', listener) + return () => ipcRenderer.removeListener('hermes:notification-action', listener) }, onPreviewFileChanged: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:preview-file-changed', listener) + return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener) }, onBackendExit: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:backend-exit', listener) + return () => ipcRenderer.removeListener('hermes:backend-exit', listener) }, onPowerResume: callback => { const listener = () => callback() ipcRenderer.on('hermes:power-resume', listener) + return () => ipcRenderer.removeListener('hermes:power-resume', listener) }, onBootProgress: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:boot-progress', listener) + return () => ipcRenderer.removeListener('hermes:boot-progress', listener) }, // First-launch bootstrap progress -- emitted by the install.ps1 stage - // runner in main.cjs (apps/desktop/electron/bootstrap-runner.cjs). + // runner in main.ts (apps/desktop/electron/bootstrap-runner.ts). // Renderer's install overlay subscribes to live events and queries the // current snapshot via getBootstrapState() to recover after a devtools // reload mid-bootstrap. @@ -204,6 +219,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { onBootstrapEvent: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:bootstrap:event', listener) + return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener) }, getVersion: () => ipcRenderer.invoke('hermes:version'), @@ -220,6 +236,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { onProgress: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:updates:progress', listener) + return () => ipcRenderer.removeListener('hermes:updates:progress', listener) } }, diff --git a/apps/desktop/electron/profile-delete-respawn.test.cjs b/apps/desktop/electron/profile-delete-respawn.test.ts similarity index 89% rename from apps/desktop/electron/profile-delete-respawn.test.cjs rename to apps/desktop/electron/profile-delete-respawn.test.ts index 07e17f78749a..2fbddfc5a596 100644 --- a/apps/desktop/electron/profile-delete-respawn.test.cjs +++ b/apps/desktop/electron/profile-delete-respawn.test.ts @@ -1,11 +1,11 @@ 'use strict' -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' -const ELECTRON_DIR = __dirname +const ELECTRON_DIR = import.meta.dirname function readElectronFile(name) { return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n') @@ -17,7 +17,7 @@ function readElectronFile(name) { // --------------------------------------------------------------------------- test('prepareProfileDeleteRequest returns the torn-down profile name', () => { - const source = readElectronFile('main.cjs') + const source = readElectronFile('main.ts') // Locate the function definition and its closing brace. const fnStart = source.indexOf('async function prepareProfileDeleteRequest(') @@ -36,7 +36,7 @@ test('prepareProfileDeleteRequest returns the torn-down profile name', () => { }) test('hermes:api handler routes profile-delete requests to the primary backend', () => { - const source = readElectronFile('main.cjs') + const source = readElectronFile('main.ts') // The handler must capture prepareProfileDeleteRequest's return value. assert.match( diff --git a/apps/desktop/electron/session-windows.test.cjs b/apps/desktop/electron/session-windows.test.ts similarity index 97% rename from apps/desktop/electron/session-windows.test.cjs rename to apps/desktop/electron/session-windows.test.ts index 78f19b859e41..48f0441d23bc 100644 --- a/apps/desktop/electron/session-windows.test.cjs +++ b/apps/desktop/electron/session-windows.test.ts @@ -1,11 +1,9 @@ -const assert = require('node:assert/strict') -const test = require('node:test') +import assert from 'node:assert/strict' +import test from 'node:test' -const { - buildSessionWindowUrl, +import { buildSessionWindowUrl, chatWindowWebPreferences, - createSessionWindowRegistry -} = require('./session-windows.cjs') + createSessionWindowRegistry } from './session-windows' // A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a // test fire the 'closed' event, mirroring the slice of the Electron API the @@ -96,6 +94,7 @@ test('registry opens one window per session and focuses on re-open', () => { const registry = createSessionWindowRegistry() let built = 0 const win = makeFakeWindow() + const factory = () => { built += 1 @@ -145,6 +144,7 @@ test('registry rebuilds a fresh window after the previous one was destroyed', () let built = 0 const second = makeFakeWindow() + const result = registry.openOrFocus('s1', () => { built += 1 @@ -158,6 +158,7 @@ test('registry rebuilds a fresh window after the previous one was destroyed', () test('registry ignores empty / non-string session ids', () => { const registry = createSessionWindowRegistry() let built = 0 + const factory = () => { built += 1 diff --git a/apps/desktop/electron/session-windows.cjs b/apps/desktop/electron/session-windows.ts similarity index 90% rename from apps/desktop/electron/session-windows.cjs rename to apps/desktop/electron/session-windows.ts index 5e2f3d4c680c..7dbba58ca064 100644 --- a/apps/desktop/electron/session-windows.cjs +++ b/apps/desktop/electron/session-windows.ts @@ -1,9 +1,9 @@ // Secondary "session windows" — one extra OS window per chat so a user can // work with multiple chats side by side. The pure, Electron-free pieces live // here so they can be unit-tested with node --test (mirroring how the rest of -// electron/*.cjs splits testable logic out of the main.cjs monolith). +// electron/*.ts splits testable logic out of the main.ts monolith). -const { pathToFileURL } = require('node:url') +import { pathToFileURL } from 'node:url' // Secondary windows open at the minimum usable size — a compact side panel for // subagent watch / cmd-click session pop-out, not a second full desktop. @@ -12,7 +12,7 @@ const SESSION_WINDOW_MIN_HEIGHT = 620 // Shared webPreferences for every window that renders the chat transcript — the // primary window AND the secondary session windows. Keeping it in one place is -// the whole point: the two BrowserWindow definitions in main.cjs used to be +// the whole point: the two BrowserWindow definitions in main.ts used to be // hand-copied, and the secondary windows silently lost `backgroundThrottling: // false`, so a streamed answer stalled until the window regained focus. // @@ -21,7 +21,7 @@ const SESSION_WINDOW_MIN_HEIGHT = 620 // blurred/occluded windows. A streaming chat app must keep painting in the // background, so every chat window opts out. The preload path is injected // because it depends on the Electron entry's __dirname. -function chatWindowWebPreferences(preloadPath) { +function chatWindowWebPreferences(preloadPath: string) { return { preload: preloadPath, contextIsolation: true, @@ -42,7 +42,7 @@ function chatWindowWebPreferences(preloadPath) { // scratch window; `watch=1` marks a spectator window (e.g. a running subagent's // session): the renderer resumes it lazily so the gateway never builds an agent // just to stream into it. -function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) { +function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch, newSession }: any = {}) { const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}` const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}` @@ -115,10 +115,8 @@ function createSessionWindowRegistry() { } } -module.exports = { - buildSessionWindowUrl, +export { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry, SESSION_WINDOW_MIN_HEIGHT, - SESSION_WINDOW_MIN_WIDTH -} + SESSION_WINDOW_MIN_WIDTH } diff --git a/apps/desktop/electron/titlebar-overlay-width.test.cjs b/apps/desktop/electron/titlebar-overlay-width.test.ts similarity index 90% rename from apps/desktop/electron/titlebar-overlay-width.test.cjs rename to apps/desktop/electron/titlebar-overlay-width.test.ts index ccec1015b4f8..c2c77d918c2d 100644 --- a/apps/desktop/electron/titlebar-overlay-width.test.cjs +++ b/apps/desktop/electron/titlebar-overlay-width.test.ts @@ -1,12 +1,7 @@ -const assert = require('node:assert/strict') -const test = require('node:test') +import assert from 'node:assert/strict' +import test from 'node:test' -const { - MACOS_TAHOE_DARWIN_MAJOR, - OVERLAY_FALLBACK_WIDTH, - macTitleBarOverlayHeight, - nativeOverlayWidth -} = require('./titlebar-overlay-width.cjs') +import { MACOS_TAHOE_DARWIN_MAJOR, macTitleBarOverlayHeight, nativeOverlayWidth, OVERLAY_FALLBACK_WIDTH } from './titlebar-overlay-width' // This static reservation is only the pre-layout FALLBACK. Once laid out the // renderer reads the exact width from navigator.windowControlsOverlay diff --git a/apps/desktop/electron/titlebar-overlay-width.cjs b/apps/desktop/electron/titlebar-overlay-width.ts similarity index 80% rename from apps/desktop/electron/titlebar-overlay-width.cjs rename to apps/desktop/electron/titlebar-overlay-width.ts index 9336ae89fcef..d31d40fec5a7 100644 --- a/apps/desktop/electron/titlebar-overlay-width.cjs +++ b/apps/desktop/electron/titlebar-overlay-width.ts @@ -1,6 +1,4 @@ -'use strict' - -const OVERLAY_FALLBACK_WIDTH = 144 +export const OVERLAY_FALLBACK_WIDTH = 144 /** * Static pre-layout reservation (px) for the right-side native window-controls @@ -16,15 +14,16 @@ const OVERLAY_FALLBACK_WIDTH = 144 * * @param {{ isMac?: boolean }} opts */ -function nativeOverlayWidth({ isMac = false } = {}) { - if (isMac) return 0 +export function nativeOverlayWidth({ isWindows = false, isWsl = false, isMac = false } = {}) { + if (isMac) {return 0} + return OVERLAY_FALLBACK_WIDTH } // macOS Tahoe ships as Darwin 25 (Sequoia is 24); the Darwin number is truthful, // unlike the product version which macOS reports as 16 or 26 depending on the // build SDK. -const MACOS_TAHOE_DARWIN_MAJOR = 25 +export const MACOS_TAHOE_DARWIN_MAJOR = 25 /** * Height (px) to pass to `titleBarOverlay` on macOS. Tahoe (Darwin 25+) @@ -36,8 +35,7 @@ const MACOS_TAHOE_DARWIN_MAJOR = 25 * * @param {{ darwinMajor?: number, titlebarHeight?: number }} opts */ -function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) { +export function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) { return darwinMajor >= MACOS_TAHOE_DARWIN_MAJOR ? 0 : titlebarHeight } -module.exports = { MACOS_TAHOE_DARWIN_MAJOR, OVERLAY_FALLBACK_WIDTH, macTitleBarOverlayHeight, nativeOverlayWidth } diff --git a/apps/desktop/electron/update-count.test.cjs b/apps/desktop/electron/update-count.test.ts similarity index 94% rename from apps/desktop/electron/update-count.test.cjs rename to apps/desktop/electron/update-count.test.ts index fdac4fd744ad..2c95fbe7adef 100644 --- a/apps/desktop/electron/update-count.test.cjs +++ b/apps/desktop/electron/update-count.test.ts @@ -1,7 +1,7 @@ -'use strict' -const test = require('node:test') -const assert = require('node:assert/strict') -const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs') +import assert from 'node:assert/strict' +import test from 'node:test' + +import { resolveBehindCount, shouldCountCommits } from './update-count' // FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0` // unconditionally, so a shallow checkout with no merge-base surfaced the bogus diff --git a/apps/desktop/electron/update-count.cjs b/apps/desktop/electron/update-count.ts similarity index 90% rename from apps/desktop/electron/update-count.cjs rename to apps/desktop/electron/update-count.ts index de8d57c4ee6b..b0bda95a9f0c 100644 --- a/apps/desktop/electron/update-count.cjs +++ b/apps/desktop/electron/update-count.ts @@ -1,5 +1,3 @@ -'use strict' - // Whether `git rev-list HEAD..origin/<branch> --count` produces a meaningful // number worth computing. On a SHALLOW checkout (installer clones with // --depth 1) the local history often shares no merge-base with the freshly @@ -19,10 +17,12 @@ function shouldCountCommits({ isShallow, hasMergeBase }) { // (developers / Docker dev images) keep the exact count path unchanged. function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMergeBase }) { if (!shouldCountCommits({ isShallow, hasMergeBase })) { - if (currentSha && targetSha && currentSha === targetSha) return 0 + if (currentSha && targetSha && currentSha === targetSha) {return 0} + return 1 // behind by an unknown amount — show a generic "update available" } + return Number.parseInt(countStr, 10) || 0 } -module.exports = { resolveBehindCount, shouldCountCommits } +export { resolveBehindCount, shouldCountCommits } diff --git a/apps/desktop/electron/update-marker.test.cjs b/apps/desktop/electron/update-marker.test.ts similarity index 85% rename from apps/desktop/electron/update-marker.test.cjs rename to apps/desktop/electron/update-marker.test.ts index d84483714c60..5207d7bf4f37 100644 --- a/apps/desktop/electron/update-marker.test.cjs +++ b/apps/desktop/electron/update-marker.test.ts @@ -1,9 +1,9 @@ /** - * Tests for electron/update-marker.cjs — the in-app update mutual-exclusion + * Tests for electron/update-marker.ts — the in-app update mutual-exclusion * marker that prevents a desktop relaunched mid-update from spawning a backend * the updater then kills in a loop (#50238). * - * Run with: node --test electron/update-marker.test.cjs + * Run with: node --test electron/update-marker.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * Why this matters: the gate must (a) report a live update only when the @@ -12,16 +12,17 @@ * strand future launches, and (c) self-heal by deleting a stale marker file. */ -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('fs') -const os = require('os') -const path = require('path') +import fs from 'fs' +import assert from 'node:assert/strict' +import test from 'node:test' +import os from 'os' +import path from 'path' -const { markerPath, isPidAlive, readLiveUpdateMarker, writeUpdateMarker, UPDATE_MARKER_MAX_AGE_MS } = require('./update-marker.cjs') +import { isPidAlive, markerPath, readLiveUpdateMarker, UPDATE_MARKER_MAX_AGE_MS, writeUpdateMarker } from './update-marker' function tmpHome(tag) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-marker-${tag}-`)) + return dir } @@ -29,10 +30,11 @@ function writeMarker(home, pid, startedAtSec) { fs.writeFileSync(markerPath(home), `${pid}\n${startedAtSec}`) } -const ALIVE = () => true // injected kill that "succeeds" => pid alive -const DEAD = () => { - const err = new Error('no such process') - err.code = 'ESRCH' +const ALIVE: typeof process.kill = () => true // injected kill that "succeeds" => pid alive + +const DEAD : typeof process.kill= () => { + const err = new Error('no such process'); + (err as any).code = 'ESRCH' throw err } @@ -84,10 +86,11 @@ test('isPidAlive: own pid is alive, impossible pid is dead', () => { test('isPidAlive: EPERM counts as alive (process owned by another user)', () => { const eperm = () => { - const err = new Error('operation not permitted') - err.code = 'EPERM' + const err = new Error('operation not permitted'); + (err as any).code = 'EPERM' throw err } + assert.equal(isPidAlive(4242, eperm), true) }) diff --git a/apps/desktop/electron/update-marker.cjs b/apps/desktop/electron/update-marker.ts similarity index 87% rename from apps/desktop/electron/update-marker.cjs rename to apps/desktop/electron/update-marker.ts index da3df6a8d4ae..6ecb3eb77d27 100644 --- a/apps/desktop/electron/update-marker.cjs +++ b/apps/desktop/electron/update-marker.ts @@ -16,20 +16,20 @@ * * This module holds the PURE, side-effect-light logic (path, pid liveness, * parse + staleness) so it is unit-testable without booting Electron. The - * polling/boot-progress wrapper lives in main.cjs where the boot-progress and + * polling/boot-progress wrapper lives in main.ts where the boot-progress and * log sinks are. */ -const fs = require('fs') -const path = require('path') +import fs from 'fs' +import path from 'path' // Even with a live-looking PID, never treat a marker older than this as a live // update. A full update (git pull + pip + desktop rebuild) is minutes, not tens // of minutes; past this the marker is almost certainly stale (e.g. the OS // recycled the pid onto an unrelated process), so the gate self-heals. -const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000 +export const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000 -function markerPath(hermesHome) { +export function markerPath(hermesHome) { return path.join(hermesHome, '.hermes-update-in-progress') } @@ -37,10 +37,12 @@ function markerPath(hermesHome) { // not deliver a signal — it just probes existence/permission. ESRCH => dead; // EPERM => alive but owned by another user (still "alive" for our purposes). // Injectable `kill` keeps it unit-testable. -function isPidAlive(pid, kill = process.kill.bind(process)) { - if (!Number.isInteger(pid) || pid <= 0) return false +export function isPidAlive(pid, kill: typeof process.kill = process.kill.bind(process)) { + if (!Number.isInteger(pid) || pid <= 0) {return false} + try { kill(pid, 0) + return true } catch (err) { return Boolean(err && err.code === 'EPERM') @@ -59,9 +61,12 @@ function isPidAlive(pid, kill = process.kill.bind(process)) { * Pure-ish: file I/O against the given path, plus an injectable pid probe and * clock for tests. */ -function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPDATE_MARKER_MAX_AGE_MS } = {}) { +export function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPDATE_MARKER_MAX_AGE_MS }: { + now?: () => number, maxAgeMs?: number, kill?: typeof process.kill +} = {}) { const file = markerPath(hermesHome) let raw + try { raw = fs.readFileSync(file, 'utf8') } catch { @@ -80,8 +85,10 @@ function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPD } catch { void 0 } + return null } + return { pid, ageMs } } @@ -107,9 +114,10 @@ function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPD * If the updater never starts (spawn failure) the marker still contains a * real PID, so `readLiveUpdateMarker` will self-heal once that PID exits. */ -function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) { +export function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) { const file = markerPath(hermesHome) const startedAt = Math.floor(now() / 1000) + try { fs.writeFileSync(file, `${pid}\n${startedAt}\n`, 'utf8') } catch { @@ -117,11 +125,3 @@ function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) { // updater will write its own when it reaches run_update. } } - -module.exports = { - UPDATE_MARKER_MAX_AGE_MS, - markerPath, - isPidAlive, - readLiveUpdateMarker, - writeUpdateMarker -} diff --git a/apps/desktop/electron/update-rebuild.test.cjs b/apps/desktop/electron/update-rebuild.test.ts similarity index 84% rename from apps/desktop/electron/update-rebuild.test.cjs rename to apps/desktop/electron/update-rebuild.test.ts index 623effa4d138..c30ccab9e261 100644 --- a/apps/desktop/electron/update-rebuild.test.cjs +++ b/apps/desktop/electron/update-rebuild.test.ts @@ -1,8 +1,8 @@ /** - * Tests for electron/update-rebuild.cjs — the retry-once policy for the desktop + * Tests for electron/update-rebuild.ts — the retry-once policy for the desktop * `--build-only` rebuild during self-update. * - * Run with: node --test electron/update-rebuild.test.cjs + * Run with: node --test electron/update-rebuild.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * Why this matters: a first rebuild can return nonzero on a still-settling tree @@ -12,10 +12,10 @@ * success, and must run at most twice. */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' +import test from 'node:test' -const { shouldRetryRebuild, runRebuildWithRetry } = require('./update-rebuild.cjs') +import { runRebuildWithRetry, shouldRetryRebuild } from './update-rebuild' test('shouldRetryRebuild retries only on a non-success exit', () => { assert.equal(shouldRetryRebuild(0), false) @@ -25,30 +25,39 @@ test('shouldRetryRebuild retries only on a non-success exit', () => { test('a clean first rebuild runs once and does not retry', async () => { const codes = [] + const result = await runRebuildWithRetry(attempt => { codes.push(attempt) + return Promise.resolve({ code: 0 }) }) + assert.deepEqual(codes, [0]) assert.equal(result.code, 0) }) test('a failed first rebuild retries once and succeeds', async () => { const codes = [] + const result = await runRebuildWithRetry(attempt => { codes.push(attempt) + return Promise.resolve({ code: attempt === 0 ? 1 : 0 }) }) + assert.deepEqual(codes, [0, 1]) assert.equal(result.code, 0) }) test('a rebuild that keeps failing runs at most twice and reports the failure', async () => { const codes = [] + const result = await runRebuildWithRetry(attempt => { codes.push(attempt) + return Promise.resolve({ code: 1, error: 'rebuild-failed' }) }) + assert.deepEqual(codes, [0, 1]) assert.equal(result.code, 1) assert.equal(result.error, 'rebuild-failed') diff --git a/apps/desktop/electron/update-rebuild.cjs b/apps/desktop/electron/update-rebuild.ts similarity index 92% rename from apps/desktop/electron/update-rebuild.cjs rename to apps/desktop/electron/update-rebuild.ts index ec8a948316dc..a2a3581eccd8 100644 --- a/apps/desktop/electron/update-rebuild.cjs +++ b/apps/desktop/electron/update-rebuild.ts @@ -1,5 +1,3 @@ -'use strict' - /** * Retry-once policy for the desktop `--build-only` rebuild during self-update. * @@ -20,10 +18,12 @@ function shouldRetryRebuild(code) { */ async function runRebuildWithRetry(rebuild) { let result = await rebuild(0) + if (shouldRetryRebuild(result.code)) { result = await rebuild(1) } + return result } -module.exports = { shouldRetryRebuild, runRebuildWithRetry } +export { runRebuildWithRetry, shouldRetryRebuild } diff --git a/apps/desktop/electron/update-relaunch.test.cjs b/apps/desktop/electron/update-relaunch.test.ts similarity index 95% rename from apps/desktop/electron/update-relaunch.test.cjs rename to apps/desktop/electron/update-relaunch.test.ts index de0a76efeecf..f46e4f1a996c 100644 --- a/apps/desktop/electron/update-relaunch.test.cjs +++ b/apps/desktop/electron/update-relaunch.test.ts @@ -1,8 +1,8 @@ /** - * Tests for electron/update-relaunch.cjs — the pure decision + script helpers + * Tests for electron/update-relaunch.ts — the pure decision + script helpers * behind the Linux in-app update relaunch (#45205). * - * Run with: node --test electron/update-relaunch.test.cjs + * Run with: node --test electron/update-relaunch.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * What this locks (review acceptance criteria for PR #45205): @@ -17,24 +17,22 @@ * (keep a working window) unless a non-interactive fallback applies. */ -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { execFileSync } = require('node:child_process') +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' -const { - unpackedDirName, - resolveUnpackedRelease, - decideRelaunchOutcome, - sandboxPreflight, - sandboxFallbackFromEnv, +import { buildRelaunchScript, collectRelaunchArgs, collectRelaunchEnv, - buildRelaunchScript, - shellQuote -} = require('./update-relaunch.cjs') + decideRelaunchOutcome, + resolveUnpackedRelease, + sandboxFallbackFromEnv, + sandboxPreflight, + shellQuote, + unpackedDirName } from './update-relaunch' const ROOT = '/home/u/.hermes/hermes-agent' const UNPACKED = path.join(ROOT, 'apps', 'desktop', 'release', 'linux-unpacked') @@ -91,6 +89,7 @@ test('decideRelaunchOutcome: only under-unpacked + sandbox-ok relaunches', () => // --------------------------------------------------------------------------- const fakeStat = (uid, mode) => () => ({ uid, mode }) + const throwStat = () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) } @@ -150,6 +149,7 @@ test('collectRelaunchArgs drops Electron internals, keeps user/launcher args', ( '--profile=work', // app flag — keep '--remote-debugging-port=9222' // internal — drop ] + assert.deepEqual(collectRelaunchArgs(argv), ['--no-sandbox', 'hermes://open/agent/42', '--profile=work']) assert.deepEqual(collectRelaunchArgs(undefined), []) }) @@ -165,6 +165,7 @@ test('collectRelaunchEnv preserves HERMES_HOME + HERMES_DESKTOP_* + sandbox opt- HOME: '/home/u', // not preserved UNRELATED: 'x' } + assert.deepEqual(collectRelaunchEnv(env), { HERMES_HOME: '/home/u/.hermes', HERMES_DESKTOP_REMOTE_URL: 'http://box:9119', @@ -207,6 +208,7 @@ test('buildRelaunchScript embeds pid/exec/args/env/cwd and is valid bash', () => // It must be syntactically valid bash (`bash -n`). Write to a temp file and lint. const tmp = path.join(os.tmpdir(), `hermes-relaunch-test-${Date.now()}.sh`) fs.writeFileSync(tmp, script) + try { execFileSync('bash', ['-n', tmp], { stdio: 'pipe' }) } finally { @@ -222,13 +224,16 @@ test('buildRelaunchScript with no args/env still lints clean', () => { env: {}, cwd: '' }) + const tmp = path.join(os.tmpdir(), `hermes-relaunch-test2-${Date.now()}.sh`) fs.writeFileSync(tmp, script) + try { execFileSync('bash', ['-n', tmp], { stdio: 'pipe' }) } finally { fs.rmSync(tmp, { force: true }) } + // exec line has no trailing args. assert.match(script, /exec '\/opt\/Hermes\/Hermes'\n/) }) diff --git a/apps/desktop/electron/update-relaunch.cjs b/apps/desktop/electron/update-relaunch.ts similarity index 89% rename from apps/desktop/electron/update-relaunch.cjs rename to apps/desktop/electron/update-relaunch.ts index 62032cde8c98..0db62c956157 100644 --- a/apps/desktop/electron/update-relaunch.cjs +++ b/apps/desktop/electron/update-relaunch.ts @@ -1,12 +1,10 @@ -'use strict' - /** - * update-relaunch.cjs — pure decision + script-generation helpers for the + * update-relaunch.ts — pure decision + script-generation helpers for the * Linux in-app update relaunch (#45205). * - * Extracted from main.cjs's `applyUpdatesPosixInApp` so the security- and + * Extracted from main.ts's `applyUpdatesPosixInApp` so the security- and * correctness-critical "do we relaunch, or land on a manual terminal state?" - * decision is unit-testable without booting Electron (main.cjs + * decision is unit-testable without booting Electron (main.ts * `require('electron')` at load). * * Background @@ -37,12 +35,14 @@ * the closeable manual-restart terminal state instead. */ -const path = require('node:path') +import path from 'node:path' // Map process.platform → electron-builder's `release/<dir>-unpacked` name. function unpackedDirName(platform) { - if (platform === 'darwin') return 'mac-unpacked' // not used (mac swaps bundles) - if (platform === 'win32') return 'win-unpacked' + if (platform === 'darwin') {return 'mac-unpacked'} // not used (mac swaps bundles) + + if (platform === 'win32') {return 'win-unpacked'} + return 'linux-unpacked' } @@ -56,15 +56,17 @@ function unpackedDirName(platform) { * `.../release/linux-unpacked-evil` can't masquerade as `.../release/linux-unpacked`. */ function resolveUnpackedRelease(execPath, updateRoot, platform) { - if (!execPath || !updateRoot) return null + if (!execPath || !updateRoot) {return null} const releaseDir = path.join(updateRoot, 'apps', 'desktop', 'release') const unpacked = path.join(releaseDir, unpackedDirName(platform)) const normalizedExec = path.resolve(String(execPath)) // execPath must be the unpacked dir itself or a descendant of it. const withSep = unpacked.endsWith(path.sep) ? unpacked : unpacked + path.sep + if (normalizedExec === unpacked || normalizedExec.startsWith(withSep)) { return unpacked } + return null } @@ -81,8 +83,10 @@ function resolveUnpackedRelease(execPath, updateRoot, platform) { * app. Closeable manual-restart terminal state. */ function decideRelaunchOutcome({ underUnpacked, sandboxOk }) { - if (!underUnpacked) return 'guiSkew' - if (!sandboxOk) return 'manual' + if (!underUnpacked) {return 'guiSkew'} + + if (!sandboxOk) {return 'manual'} + return 'relaunch' } @@ -99,9 +103,10 @@ function decideRelaunchOutcome({ underUnpacked, sandboxOk }) { * `statSync` is injectable so this is testable without a real setuid file. */ function sandboxPreflight(unpackedDir, statSync) { - if (!unpackedDir) return { ok: false, reason: 'no-unpacked-dir', path: null } + if (!unpackedDir) {return { ok: false, reason: 'no-unpacked-dir', path: null }} const sandboxPath = path.join(unpackedDir, 'chrome-sandbox') let st + try { st = statSync(sandboxPath) } catch { @@ -109,15 +114,20 @@ function sandboxPreflight(unpackedDir, statSync) { // sandbox; nothing to block the relaunch. return { ok: true, reason: 'no-sandbox-helper', path: sandboxPath } } + const ownedByRoot = st.uid === 0 const hasSetuid = (st.mode & 0o4000) !== 0 + if (ownedByRoot && hasSetuid) { return { ok: true, reason: 'launchable', path: sandboxPath } } + if (!ownedByRoot && !hasSetuid) { return { ok: false, reason: 'not-root-not-setuid', path: sandboxPath } } - if (!ownedByRoot) return { ok: false, reason: 'not-root', path: sandboxPath } + + if (!ownedByRoot) {return { ok: false, reason: 'not-root', path: sandboxPath }} + return { ok: false, reason: 'not-setuid', path: sandboxPath } } @@ -126,7 +136,7 @@ function sandboxPreflight(unpackedDir, statSync) { * environment. The reviewer asked us to integrate with any existing * `--no-sandbox` / chrome-sandbox handling. A repo grep found NO existing * non-interactive sandbox fallback in the desktop app (the only chrome-sandbox - * reference is documentation in scripts/before-pack.cjs). The one signal that + * reference is documentation in scripts/before-pack.ts). The one signal that * DOES exist is the standard Electron escape hatch: ELECTRON_DISABLE_SANDBOX=1 * (and the equivalent `--no-sandbox` already present in the launch args). If * the user has set that, the rebuilt binary will start even with a broken @@ -137,8 +147,11 @@ function sandboxPreflight(unpackedDir, statSync) { */ function sandboxFallbackFromEnv(env, launchArgs) { const disable = String((env && env.ELECTRON_DISABLE_SANDBOX) || '').trim() - if (disable === '1' || disable.toLowerCase() === 'true') return true - if (Array.isArray(launchArgs) && launchArgs.some(a => a === '--no-sandbox')) return true + + if (disable === '1' || disable.toLowerCase() === 'true') {return true} + + if (Array.isArray(launchArgs) && launchArgs.some(a => a === '--no-sandbox')) {return true} + return false } @@ -176,9 +189,11 @@ const INTERNAL_ARG_PREFIXES = [ * the exec path itself; there is no entry-script arg as in a dev run). */ function collectRelaunchArgs(argv) { - if (!Array.isArray(argv)) return [] + if (!Array.isArray(argv)) {return []} + return argv.filter(arg => { - if (typeof arg !== 'string' || arg.length === 0) return false + if (typeof arg !== 'string' || arg.length === 0) {return false} + return !INTERNAL_ARG_PREFIXES.some(prefix => prefix.endsWith('=') ? arg.startsWith(prefix) : arg === prefix || arg.startsWith(prefix + '=') ) @@ -197,13 +212,17 @@ const PRESERVED_ENV_PREFIXES = ['HERMES_DESKTOP_'] function collectRelaunchEnv(env) { const out = {} - if (!env || typeof env !== 'object') return out + + if (!env || typeof env !== 'object') {return out} + for (const [key, value] of Object.entries(env)) { - if (value == null) continue + if (value == null) {continue} + if (PRESERVED_ENV_KEYS.includes(key) || PRESERVED_ENV_PREFIXES.some(p => key.startsWith(p))) { out[key] = String(value) } } + return out } @@ -223,8 +242,10 @@ function buildRelaunchScript({ pid, execPath, args, env, cwd }) { const exports = Object.entries(env || {}) .map(([k, v]) => `export ${k}=${shellQuote(v)}`) .join('\n') + const quotedArgs = (args || []).map(shellQuote).join(' ') const cwdLine = cwd ? `cd ${shellQuote(cwd)} 2>/dev/null || true` : '' + // NOTE: `exec` replaces the watcher process with the relaunched app, so the // re-exec inherits exactly the env/cwd we set above. return `#!/bin/bash @@ -249,17 +270,15 @@ exec ${shellQuote(execPath)}${quotedArgs ? ' ' + quotedArgs : ''} ` } -module.exports = { - unpackedDirName, - resolveUnpackedRelease, - decideRelaunchOutcome, - sandboxPreflight, - sandboxFallbackFromEnv, +export { buildRelaunchScript, collectRelaunchArgs, collectRelaunchEnv, - buildRelaunchScript, - shellQuote, + decideRelaunchOutcome, INTERNAL_ARG_PREFIXES, PRESERVED_ENV_KEYS, - PRESERVED_ENV_PREFIXES -} + PRESERVED_ENV_PREFIXES, + resolveUnpackedRelease, + sandboxFallbackFromEnv, + sandboxPreflight, + shellQuote, + unpackedDirName } diff --git a/apps/desktop/electron/update-remote.test.cjs b/apps/desktop/electron/update-remote.test.ts similarity index 91% rename from apps/desktop/electron/update-remote.test.cjs rename to apps/desktop/electron/update-remote.test.ts index 0dfba970138b..c4e468a285bc 100644 --- a/apps/desktop/electron/update-remote.test.cjs +++ b/apps/desktop/electron/update-remote.test.ts @@ -1,8 +1,8 @@ /** - * Tests for electron/update-remote.cjs — the remote-detection helpers that + * Tests for electron/update-remote.ts — the remote-detection helpers that * keep passive update checks off the SSH origin for official installs. * - * Run with: node --test electron/update-remote.test.cjs + * Run with: node --test electron/update-remote.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * Why this matters: a public install can carry @@ -15,16 +15,14 @@ * never prompts and should keep the normal fetch path). */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' +import test from 'node:test' -const { - OFFICIAL_REPO_HTTPS_URL, - OFFICIAL_REPO_CANONICAL, - canonicalGitHubRemote, +import { canonicalGitHubRemote, + isOfficialSshRemote, isSshRemote, - isOfficialSshRemote -} = require('./update-remote.cjs') + OFFICIAL_REPO_CANONICAL, + OFFICIAL_REPO_HTTPS_URL } from './update-remote' test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => { assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL) diff --git a/apps/desktop/electron/update-remote.cjs b/apps/desktop/electron/update-remote.ts similarity index 79% rename from apps/desktop/electron/update-remote.cjs rename to apps/desktop/electron/update-remote.ts index 1e99bbe88771..b6b17ab0a7a7 100644 --- a/apps/desktop/electron/update-remote.cjs +++ b/apps/desktop/electron/update-remote.ts @@ -8,8 +8,8 @@ * which needs no auth and cannot prompt. Active update/apply flows are left * unchanged. * - * Extracted from main.cjs so the security-critical remote detection is unit - * testable without booting Electron (main.cjs requires('electron') at load). + * Extracted from main.ts so the security-critical remote detection is unit + * testable without booting Electron (main.ts requires('electron') at load). */ const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git' @@ -19,8 +19,9 @@ const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent' // no trailing slash, no .git suffix) so SSH and HTTPS forms of the same repo // compare equal. function canonicalGitHubRemote(url) { - if (!url) return '' + if (!url) {return ''} let value = String(url).trim() + if (value.startsWith('git@github.com:')) { value = `github.com/${value.slice('git@github.com:'.length)}` } else if (value.startsWith('ssh://git@github.com/')) { @@ -28,13 +29,17 @@ function canonicalGitHubRemote(url) { } else { try { const parsed = new URL(value) - if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}` + + if (parsed.hostname && parsed.pathname) {value = `${parsed.hostname}${parsed.pathname}`} } catch { // Leave non-URL forms unchanged. } } + value = value.trim().replace(/\/+$/, '') - if (value.endsWith('.git')) value = value.slice(0, -4) + + if (value.endsWith('.git')) {value = value.slice(0, -4)} + return value.toLowerCase() } @@ -42,6 +47,7 @@ function isSshRemote(url) { const value = String(url || '') .trim() .toLowerCase() + return value.startsWith('git@') || value.startsWith('ssh://') } @@ -49,10 +55,8 @@ function isOfficialSshRemote(url) { return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL } -module.exports = { - OFFICIAL_REPO_HTTPS_URL, - OFFICIAL_REPO_CANONICAL, - canonicalGitHubRemote, +export { canonicalGitHubRemote, + isOfficialSshRemote, isSshRemote, - isOfficialSshRemote -} + OFFICIAL_REPO_CANONICAL, + OFFICIAL_REPO_HTTPS_URL } diff --git a/apps/desktop/electron/vscode-marketplace.test.cjs b/apps/desktop/electron/vscode-marketplace.test.ts similarity index 95% rename from apps/desktop/electron/vscode-marketplace.test.cjs rename to apps/desktop/electron/vscode-marketplace.test.ts index 45169044bfa3..6c8b8e6b76e6 100644 --- a/apps/desktop/electron/vscode-marketplace.test.cjs +++ b/apps/desktop/electron/vscode-marketplace.test.ts @@ -1,9 +1,7 @@ -'use strict' +import assert from 'node:assert' +import test from 'node:test' -const assert = require('node:assert') -const test = require('node:test') - -const { __testing, extractThemes, readCentralDirectory } = require('./vscode-marketplace.cjs') +import { __testing, extractThemes, readCentralDirectory } from './vscode-marketplace' // Build a minimal zip with stored (uncompressed) entries so the test controls // the bytes exactly — exercises the central-directory reader + theme extraction @@ -72,6 +70,7 @@ test('extractThemes reads contributed color themes (resolving ./ paths)', () => themes: [{ label: 'Dracula', uiTheme: 'vs-dark', path: './themes/dracula.json' }] } }) + const themeJson = JSON.stringify({ name: 'Dracula', type: 'dark', colors: { 'editor.background': '#282a36' } }) const zip = makeZip([ diff --git a/apps/desktop/electron/vscode-marketplace.cjs b/apps/desktop/electron/vscode-marketplace.ts similarity index 98% rename from apps/desktop/electron/vscode-marketplace.cjs rename to apps/desktop/electron/vscode-marketplace.ts index 55e49bc30ecd..4ad72d343adc 100644 --- a/apps/desktop/electron/vscode-marketplace.cjs +++ b/apps/desktop/electron/vscode-marketplace.ts @@ -1,5 +1,3 @@ -'use strict' - /** * VS Code Marketplace color-theme fetcher (main process). * @@ -14,8 +12,8 @@ * zip library into the desktop bundle for a feature this small. */ -const https = require('node:https') -const zlib = require('node:zlib') +import https from 'node:https' +import zlib from 'node:zlib' const GALLERY_QUERY_URL = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery' const VSIX_ASSET_TYPE = 'Microsoft.VisualStudio.Services.VSIXPackage' @@ -30,7 +28,7 @@ function request( url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS -) { +): Promise<Buffer<ArrayBuffer>> { return new Promise((resolve, reject) => { const req = https.request(url, { method, headers }, res => { const status = res.statusCode ?? 0 @@ -102,6 +100,7 @@ async function resolveExtension(id) { // IncludeCategoryAndTags | IncludeLatestVersionOnly = 914. flags: 914 }) + const extension = json?.results?.[0]?.extensions?.[0] if (!extension) { @@ -127,6 +126,7 @@ async function resolveExtension(id) { /** POST an ExtensionQuery payload and return the parsed gallery response. */ async function queryGallery(payload, { maxBytes = 4 * 1024 * 1024 } = {}) { const body = JSON.stringify(payload) + const raw = await request(GALLERY_QUERY_URL, { method: 'POST', headers: { @@ -332,10 +332,12 @@ async function fetchMarketplaceThemes(id) { return { extensionId: trimmed, displayName, themes } } -module.exports = { - fetchMarketplaceThemes, - searchMarketplaceThemes, +const __testing = { themeEntryName, looksLikeIconTheme } + +export { + __testing, extractThemes, + fetchMarketplaceThemes, readCentralDirectory, - __testing: { themeEntryName, looksLikeIconTheme } + searchMarketplaceThemes } diff --git a/apps/desktop/electron/window-state.test.cjs b/apps/desktop/electron/window-state.test.ts similarity index 96% rename from apps/desktop/electron/window-state.test.cjs rename to apps/desktop/electron/window-state.test.ts index a0f68ce333cd..83765270675b 100644 --- a/apps/desktop/electron/window-state.test.cjs +++ b/apps/desktop/electron/window-state.test.ts @@ -4,19 +4,17 @@ * clamping, and the debounce that collapses mid-drag write storms. */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' +import test from 'node:test' -const { - DEFAULT_WIDTH, +import { computeWindowOptions, + debounce, DEFAULT_HEIGHT, - MIN_WIDTH, + DEFAULT_WIDTH, MIN_HEIGHT, - sanitizeWindowState, + MIN_WIDTH, onScreen, - computeWindowOptions, - debounce -} = require('./window-state.cjs') + sanitizeWindowState } from './window-state' // A single 1920×1080 monitor (work area trimmed for the taskbar). const PRIMARY = [{ workArea: { x: 0, y: 0, width: 1920, height: 1040 } }] @@ -121,6 +119,7 @@ test('computeWindowOptions does not clamp when displays are unknown', () => { test('debounce coalesces a burst into one trailing run', t => { t.mock.timers.enable({ apis: ['setTimeout'] }) let calls = 0 + const d = debounce(() => { calls += 1 }, 250) @@ -138,6 +137,7 @@ test('debounce coalesces a burst into one trailing run', t => { test('debounce.flush runs now and cancels the pending timer', t => { t.mock.timers.enable({ apis: ['setTimeout'] }) let calls = 0 + const d = debounce(() => { calls += 1 }, 250) diff --git a/apps/desktop/electron/window-state.cjs b/apps/desktop/electron/window-state.ts similarity index 82% rename from apps/desktop/electron/window-state.cjs rename to apps/desktop/electron/window-state.ts index 6157e469b24e..919d3274547a 100644 --- a/apps/desktop/electron/window-state.cjs +++ b/apps/desktop/electron/window-state.ts @@ -2,7 +2,7 @@ * Pure geometry helpers for window-state.json — restoring the main window's * size, position, and maximized flag across launches. Side-effect-free so the * part that actually matters (rejecting garbage + off-screen bounds) is - * unit-testable without booting Electron; main.cjs owns the file I/O and the + * unit-testable without booting Electron; main.ts owns the file I/O and the * live `screen` displays. */ @@ -21,41 +21,59 @@ const MIN_VISIBLE = 48 const finite = v => typeof v === 'number' && Number.isFinite(v) const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi)) +interface SanitizedWindowState{ + width: number, height: number, isMaximized: boolean, x?: number,y?: number +} + // Parse raw JSON → clean state, or null if garbage. width/height are required // and floored; x/y survive only as a finite pair; isMaximized is strict. -function sanitizeWindowState(raw) { - if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) return null +function sanitizeWindowState(raw?: any): SanitizedWindowState | null - const state = { + + { + if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) {return null} + + const state: SanitizedWindowState = { width: Math.max(MIN_WIDTH, Math.round(raw.width)), height: Math.max(MIN_HEIGHT, Math.round(raw.height)), - isMaximized: raw.isMaximized === true + isMaximized: raw.isMaximized === true, } + if (finite(raw.x) && finite(raw.y)) { - state.x = Math.round(raw.x) + state.x = Math.round(raw.x); state.y = Math.round(raw.y) } + return state } // True when `bounds` overlaps some display's work area by ≥ MIN_VISIBLE on both // axes. `displays` is Electron's screen.getAllDisplays() shape. function onScreen(bounds, displays) { - if (!Array.isArray(displays)) return false + if (!Array.isArray(displays)) {return false} + return displays.some(({ workArea: a } = {}) => { - if (!a) return false + if (!a) {return false} const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x) const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y) + return x >= MIN_VISIBLE && y >= MIN_VISIBLE }) } +interface WindowOptions { + width: number + height: number + x?: number + y?: number +} + // Sanitized state (or null) → BrowserWindow size/position options. Always sets // width/height, capped to the largest current display so a size saved on a // since-disconnected bigger monitor can't exceed any screen the user now has. // Sets x/y only when still on-screen; otherwise Electron centers the window. -function computeWindowOptions(state, displays) { - const opts = { +function computeWindowOptions(state, displays): WindowOptions { + const opts: WindowOptions = { width: finite(state?.width) ? state.width : DEFAULT_WIDTH, height: finite(state?.height) ? state.height : DEFAULT_HEIGHT } @@ -67,6 +85,7 @@ function computeWindowOptions(state, displays) { : m, { width: 0, height: 0 } ) + if (cap.width && cap.height) { opts.width = clamp(opts.width, MIN_WIDTH, cap.width) opts.height = clamp(opts.height, MIN_HEIGHT, cap.height) @@ -78,9 +97,10 @@ function computeWindowOptions(state, displays) { finite(state.y) && onScreen({ x: state.x, y: state.y, width: opts.width, height: opts.height }, displays) ) { - opts.x = state.x + opts.x = state.x; opts.y = state.y } + return opts } @@ -89,6 +109,7 @@ function computeWindowOptions(state, displays) { // cancels the pending timer — used on close, before the window is gone. function debounce(fn, delayMs) { let timer = null + const debounced = () => { clearTimeout(timer) timer = setTimeout(() => { @@ -96,22 +117,22 @@ function debounce(fn, delayMs) { fn() }, delayMs) } + debounced.flush = () => { clearTimeout(timer) timer = null fn() } + return debounced } -module.exports = { - DEFAULT_WIDTH, +export { computeWindowOptions, + debounce, DEFAULT_HEIGHT, - MIN_WIDTH, + DEFAULT_WIDTH, MIN_HEIGHT, MIN_VISIBLE, - sanitizeWindowState, + MIN_WIDTH, onScreen, - computeWindowOptions, - debounce -} + sanitizeWindowState } diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.ts similarity index 88% rename from apps/desktop/electron/windows-child-process.test.cjs rename to apps/desktop/electron/windows-child-process.test.ts index c15dc3b7b505..d1b4242f76cd 100644 --- a/apps/desktop/electron/windows-child-process.test.cjs +++ b/apps/desktop/electron/windows-child-process.test.ts @@ -1,11 +1,14 @@ -'use strict' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') +const ELECTRON_DIR = path.dirname(fileURLToPath(import.meta.url)) + +// TODO FIXME these tests all grep source code for specific things. This is an antipattern. +// Tests should NEVER read src, only assert behavior. -const ELECTRON_DIR = __dirname function readElectronFile(name) { return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n') @@ -24,9 +27,9 @@ function requireHiddenChildOptions(source, needle) { } test('desktop background child processes opt into hidden Windows consoles', () => { - const source = readElectronFile('main.cjs') + const source = readElectronFile('main.ts') - assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/) + assert.match(source, /function hiddenWindowsChildOptions\(options: any = \{\}\)/) requireHiddenChildOptions(source, "execFileSync(\n 'reg'") requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/) @@ -45,7 +48,7 @@ test('desktop background child processes opt into hidden Windows consoles', () = }) test('desktop backend launches console python so child consoles are inherited, not pythonw', () => { - const source = readElectronFile('main.cjs') + const source = readElectronFile('main.ts') // The flash fix is structural: the backend runs as a console-subsystem // python.exe under hiddenWindowsChildOptions() (-> CREATE_NO_WINDOW), so it @@ -75,7 +78,7 @@ test('desktop backend launches console python so child consoles are inherited, n }) test('desktop backend teardown tree-kills Windows backend descendants', () => { - const source = readElectronFile('main.cjs') + const source = readElectronFile('main.ts') const helperIndex = source.indexOf('function stopBackendChild(child)') assert.notEqual(helperIndex, -1, 'missing backend teardown helper') @@ -98,7 +101,7 @@ test('desktop backend teardown tree-kills Windows backend descendants', () => { }) test('intentional or interactive desktop child processes stay documented', () => { - const source = readElectronFile('main.cjs') + const source = readElectronFile('main.ts') assert.match(source, /windowsHide: false/) assert.match(source, /handOffWindowsBootstrapRecovery/) @@ -109,7 +112,7 @@ test('intentional or interactive desktop child processes stay documented', () => }) test('bootstrap PowerShell runner hides Windows console children', () => { - const source = readElectronFile('bootstrap-runner.cjs') + const source = readElectronFile('bootstrap-runner.ts') assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/) requireHiddenChildOptions(source, /spawn\(\s*ps,\s*fullArgs/) diff --git a/apps/desktop/electron/windows-hermes-resolution.test.cjs b/apps/desktop/electron/windows-hermes-resolution.test.ts similarity index 85% rename from apps/desktop/electron/windows-hermes-resolution.test.cjs rename to apps/desktop/electron/windows-hermes-resolution.test.ts index 40e2658a1220..1b91a8acb7f4 100644 --- a/apps/desktop/electron/windows-hermes-resolution.test.cjs +++ b/apps/desktop/electron/windows-hermes-resolution.test.ts @@ -1,9 +1,7 @@ -'use strict' - -// Regression guards for Windows `hermes` resolution in main.cjs. +// Regression guards for Windows `hermes` resolution in main.ts. // -// main.cjs has no module.exports, so these follow the repo's source-assertion -// test pattern (see windows-child-process.test.cjs). They pin the two Windows +// main.ts has no module.exports, so these follow the repo's source-assertion +// test pattern (see windows-child-process.test.ts). They pin the two Windows // resolution bugs that caused desktop reinstall loops: // 1. findOnPath() tried the empty extension FIRST, so an extensionless // Git-Bash `hermes` shim shadowed the real hermes.cmd/hermes.exe; the @@ -20,13 +18,16 @@ // Retry / "Repair install" resolved the same dead interpreter instead of // falling through to the bootstrap installer. -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) function readMain() { - return fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8').replace(/\r\n/g, '\n') + return fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8').replace(/\r\n/g, '\n') } test('findOnPath tries PATHEXT extensions before the bare (empty) name on Windows', () => { @@ -66,7 +67,7 @@ test('Windows bootstrap recovery chooses --update when any real-install signal i test('unwrapWindowsVenvHermesCommand smoke-tests the venv python before trusting it', () => { const source = readMain() const fnStart = source.indexOf('function unwrapWindowsVenvHermesCommand(') - assert.notEqual(fnStart, -1, 'unwrapWindowsVenvHermesCommand must exist in main.cjs') + assert.notEqual(fnStart, -1, 'unwrapWindowsVenvHermesCommand must exist in main.ts') // Slice out just the function body (up to the next top-level function decl) const fnEnd = source.indexOf('\nfunction ', fnStart + 1) const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd) diff --git a/apps/desktop/electron/windows-user-env.test.cjs b/apps/desktop/electron/windows-user-env.test.ts similarity index 94% rename from apps/desktop/electron/windows-user-env.test.cjs rename to apps/desktop/electron/windows-user-env.test.ts index 3fee15981901..17c9a4a7c7e1 100644 --- a/apps/desktop/electron/windows-user-env.test.cjs +++ b/apps/desktop/electron/windows-user-env.test.ts @@ -1,7 +1,7 @@ -const assert = require('node:assert/strict') -const { test } = require('node:test') +import assert from 'node:assert/strict' +import { test } from 'node:test' -const { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } = require('./windows-user-env.cjs') +import { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } from './windows-user-env' // ── parseRegQueryValue ───────────────────────────────────────────────────── @@ -42,25 +42,32 @@ test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => test('readWindowsUserEnvVar returns null off Windows without spawning', () => { let spawned = false + const exec = () => { spawned = true + return '' } + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null) assert.equal(spawned, false) }) test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => { const calls = [] + const exec = (cmd, args) => { calls.push([cmd, args]) + return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n' } + const value = readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', env: { DRIVE: 'F:' }, exec }) + assert.equal(value, 'F:\\Hermes') assert.deepEqual(calls, [['reg', ['query', 'HKCU\\Environment', '/v', 'HERMES_HOME']]]) }) @@ -69,6 +76,7 @@ test('readWindowsUserEnvVar returns null when reg exits non-zero (value missing) const exec = () => { throw new Error('reg exited 1') } + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null) }) diff --git a/apps/desktop/electron/windows-user-env.cjs b/apps/desktop/electron/windows-user-env.ts similarity index 83% rename from apps/desktop/electron/windows-user-env.cjs rename to apps/desktop/electron/windows-user-env.ts index 4bfaba1570df..a6bc99b08306 100644 --- a/apps/desktop/electron/windows-user-env.cjs +++ b/apps/desktop/electron/windows-user-env.ts @@ -1,4 +1,4 @@ -// windows-user-env.cjs +// windows-user-env.ts // // Read a User-scoped environment variable straight from the Windows registry // (HKCU\Environment). @@ -10,7 +10,7 @@ // gap silently sends the backend to the default %LOCALAPPDATA%\hermes. Reading // the live registry value closes the gap. See #45471. -const { execFileSync } = require('node:child_process') +import { execFileSync } from 'node:child_process' // Parse the output of `reg query HKCU\Environment /v <name>`, which looks like: // @@ -20,15 +20,18 @@ const { execFileSync } = require('node:child_process') // Returns the raw value string (spaces inside the value preserved), or null when // the requested value line isn't present. function parseRegQueryValue(stdout, name) { - if (!stdout || !name) return null + if (!stdout || !name) {return null} const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/ + for (const rawLine of String(stdout).split(/\r?\n/)) { const line = rawLine.trim() const match = line.match(typePattern) + if (match && match[1].toLowerCase() === name.toLowerCase()) { return match[2] } } + return null } @@ -36,9 +39,11 @@ function parseRegQueryValue(stdout, name) { // unexpanded references; plain REG_SZ paths have none, so this is a no-op for // the common F:\... case. Unknown references are left verbatim. function expandWindowsEnvRefs(value, env = process.env) { - if (!value) return value + if (!value) {return value} + return value.replace(/%([^%]+)%/g, (whole, name) => { const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase()) + return key != null && env[key] != null ? env[key] : whole }) } @@ -46,9 +51,12 @@ function expandWindowsEnvRefs(value, env = process.env) { // Read a User-scoped env var from HKCU\Environment. Windows-only: returns null // off-Windows (without spawning), on any spawn error, when `reg` exits non-zero // (the value doesn't exist), or when the value is empty. -function readWindowsUserEnvVar(name, { platform = process.platform, env = process.env, exec = execFileSync } = {}) { - if (platform !== 'win32' || !name) return null +function readWindowsUserEnvVar(name, { platform = process.platform, env = process.env, exec = execFileSync }: { + platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv, exec?: typeof execFileSync | ((file?: string, args?: any) => string) +} = {}) { + if (platform !== 'win32' || !name) {return null} let stdout + try { stdout = exec('reg', ['query', 'HKCU\\Environment', '/v', name], { encoding: 'utf8', @@ -59,14 +67,15 @@ function readWindowsUserEnvVar(name, { platform = process.platform, env = proces // `reg` missing, or value absent (reg exits 1) — caller falls back. return null } + const raw = parseRegQueryValue(stdout, name) - if (raw == null) return null + + if (raw == null) {return null} const expanded = expandWindowsEnvRefs(raw, env).trim() + return expanded || null } -module.exports = { - expandWindowsEnvRefs, +export { expandWindowsEnvRefs, parseRegQueryValue, - readWindowsUserEnvVar -} + readWindowsUserEnvVar } diff --git a/apps/desktop/electron/workspace-cwd.test.cjs b/apps/desktop/electron/workspace-cwd.test.ts similarity index 77% rename from apps/desktop/electron/workspace-cwd.test.cjs rename to apps/desktop/electron/workspace-cwd.test.ts index 85a044ab3beb..6737c3582821 100644 --- a/apps/desktop/electron/workspace-cwd.test.cjs +++ b/apps/desktop/electron/workspace-cwd.test.ts @@ -1,14 +1,14 @@ /** - * Tests for electron/workspace-cwd.cjs. + * Tests for electron/workspace-cwd.ts. * - * Run with: node --test electron/workspace-cwd.test.cjs + * Run with: node --test electron/workspace-cwd.test.ts */ -const test = require('node:test') -const assert = require('node:assert/strict') -const path = require('node:path') +import assert from 'node:assert/strict' +import path from 'node:path' +import test from 'node:test' -const { isPackagedInstallPath } = require('./workspace-cwd.cjs') +import { isPackagedInstallPath } from './workspace-cwd' const installRoot = path.resolve('/opt/Hermes') diff --git a/apps/desktop/electron/workspace-cwd.cjs b/apps/desktop/electron/workspace-cwd.ts similarity index 78% rename from apps/desktop/electron/workspace-cwd.cjs rename to apps/desktop/electron/workspace-cwd.ts index bb5da7771489..332ac92dca6c 100644 --- a/apps/desktop/electron/workspace-cwd.cjs +++ b/apps/desktop/electron/workspace-cwd.ts @@ -1,7 +1,7 @@ -const path = require('node:path') +import path from 'node:path' /** True when `dir` lives inside a packaged app bundle / install tree. */ -function isPackagedInstallPath(dir, { installRoots, isPackaged }) { +function isPackagedInstallPath(dir, { installRoots, isPackaged }: { installRoots: string[], isPackaged:boolean }) { if (!isPackaged || !dir) { return false } @@ -21,7 +21,7 @@ function isPackagedInstallPath(dir, { installRoots, isPackaged }) { return true } - const rel = path.relative(root, resolved) + const rel = path.relative(root, resolved) as any if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { return true @@ -31,4 +31,4 @@ function isPackagedInstallPath(dir, { installRoots, isPackaged }) { return false } -module.exports = { isPackagedInstallPath } +export { isPackagedInstallPath } diff --git a/apps/desktop/electron/wsl-clipboard-image.test.cjs b/apps/desktop/electron/wsl-clipboard-image.test.ts similarity index 92% rename from apps/desktop/electron/wsl-clipboard-image.test.cjs rename to apps/desktop/electron/wsl-clipboard-image.test.ts index 343adc1f6d69..da3481ffa176 100644 --- a/apps/desktop/electron/wsl-clipboard-image.test.cjs +++ b/apps/desktop/electron/wsl-clipboard-image.test.ts @@ -1,12 +1,10 @@ -const assert = require('node:assert/strict') -const test = require('node:test') +import assert from 'node:assert/strict' +import test from 'node:test' -const { - decodeClipboardImageBase64, +import { decodeClipboardImageBase64, encodePowerShellCommand, powershellCandidates, - readWslWindowsClipboardImage -} = require('./wsl-clipboard-image.cjs') + readWslWindowsClipboardImage } from './wsl-clipboard-image' const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) @@ -49,10 +47,12 @@ test('decodeClipboardImageBase64 rejects base64 without a PNG signature', () => test('readWslWindowsClipboardImage decodes the first candidate that returns a PNG', () => { const png = fakePngBuffer() const calls = [] - const exec = (cmd, args) => { + + const exec = ((cmd, args) => { calls.push({ cmd, args }) + return png.toString('base64') - } + }) as any const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe'] }) assert.ok(result && result.equals(png)) @@ -65,15 +65,18 @@ test('readWslWindowsClipboardImage decodes the first candidate that returns a PN test('readWslWindowsClipboardImage returns null and stops when stdout is empty (no image)', () => { let count = 0 - const exec = () => { + + const exec =(() => { count += 1 - return '' - } + + return '' + } )as any const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'] }) + assert.equal(result, null) // Empty stdout means "no image on the clipboard" — don't probe further candidates. assert.equal(count, 1) @@ -82,18 +85,22 @@ test('readWslWindowsClipboardImage returns null and stops when stdout is empty ( test('readWslWindowsClipboardImage falls through to the next candidate when one throws', () => { const png = fakePngBuffer() const seen = [] + const exec = cmd => { seen.push(cmd) + if (cmd === 'powershell.exe') { throw Object.assign(new Error('not found'), { code: 'ENOENT' }) } - return png.toString('base64') + + return png.toString('base64') as any } const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'] }) + assert.ok(result && result.equals(png)) assert.deepEqual(seen, ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']) }) diff --git a/apps/desktop/electron/wsl-clipboard-image.cjs b/apps/desktop/electron/wsl-clipboard-image.ts similarity index 90% rename from apps/desktop/electron/wsl-clipboard-image.cjs rename to apps/desktop/electron/wsl-clipboard-image.ts index c81fe7b2a60b..23fe45e8b157 100644 --- a/apps/desktop/electron/wsl-clipboard-image.cjs +++ b/apps/desktop/electron/wsl-clipboard-image.ts @@ -1,7 +1,7 @@ // Pull a Windows-host clipboard image from inside WSL2 via PowerShell (WSLg // bridges text but not images). Returns PNG bytes or null; exec injectable. -const { execFileSync } = require('node:child_process') +import { execFileSync } from 'node:child_process' // STA is mandatory: System.Windows.Forms.Clipboard throws ThreadStateException // off a single-threaded apartment. We emit base64 (not raw bytes) so the PNG @@ -33,9 +33,11 @@ function powershellCandidates() { function decodeClipboardImageBase64(stdout) { const b64 = String(stdout || '').trim() - if (!b64) return null + + if (!b64) {return null} let buffer + try { buffer = Buffer.from(b64, 'base64') } catch { @@ -44,6 +46,7 @@ function decodeClipboardImageBase64(stdout) { // Guard against partial / garbage output: require a real PNG signature. const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + if (buffer.length < PNG_SIGNATURE.length || !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) { return null } @@ -54,7 +57,7 @@ function decodeClipboardImageBase64(stdout) { // Read the Windows clipboard image from inside WSL. Returns a PNG Buffer, or // null when there's no image, PowerShell is unreachable, or output is invalid. // Linux-only by contract (caller gates on IS_WSL); never throws. -function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powershellCandidates() } = {}) { +function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powershellCandidates() }: {exec?: typeof execFileSync, candidates?: string[]} = {}) { const encoded = encodePowerShellCommand(PS_SCRIPT) for (const ps of candidates) { @@ -72,10 +75,13 @@ function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powers stdio: ['ignore', 'pipe', 'ignore'] } ) + const decoded = decodeClipboardImageBase64(stdout) - if (decoded) return decoded + + if (decoded) {return decoded} + // Empty stdout = no image on the clipboard; stop, don't try fallbacks. - if (String(stdout || '').trim() === '') return null + if (String(stdout || '').trim() === '') {return null} } catch { // This powershell.exe candidate is missing/failed — try the next one. } @@ -84,9 +90,7 @@ function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powers return null } -module.exports = { - decodeClipboardImageBase64, +export { decodeClipboardImageBase64, encodePowerShellCommand, powershellCandidates, - readWslWindowsClipboardImage -} + readWslWindowsClipboardImage } diff --git a/apps/desktop/electron/zoom.test.cjs b/apps/desktop/electron/zoom.test.ts similarity index 89% rename from apps/desktop/electron/zoom.test.cjs rename to apps/desktop/electron/zoom.test.ts index da104a526303..0636c8c52e2a 100644 --- a/apps/desktop/electron/zoom.test.cjs +++ b/apps/desktop/electron/zoom.test.ts @@ -4,10 +4,10 @@ * roundtrip stability of the preset percentages. */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' +import test from 'node:test' -const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs') +import { clampZoomLevel, percentToZoomLevel, ZOOM_STORAGE_KEY, zoomLevelToPercent } from './zoom' test('storage key stays stable so persisted zoom survives upgrades', () => { assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel') @@ -43,6 +43,7 @@ test('preset percentages roundtrip within rounding', () => { test('conversion is monotonic across the preset range', () => { const levels = [90, 100, 110, 125, 150, 175].map(percentToZoomLevel) + for (let i = 1; i < levels.length; i++) { assert.ok(levels[i] > levels[i - 1]) } diff --git a/apps/desktop/electron/zoom.cjs b/apps/desktop/electron/zoom.ts similarity index 64% rename from apps/desktop/electron/zoom.cjs rename to apps/desktop/electron/zoom.ts index 41477f41b420..1f865cfbfa43 100644 --- a/apps/desktop/electron/zoom.cjs +++ b/apps/desktop/electron/zoom.ts @@ -6,29 +6,24 @@ * factor = 1.2 ^ level. */ -const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel' +export const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel' const ZOOM_FACTOR_BASE = 1.2 const MIN_ZOOM_LEVEL = -9 const MAX_ZOOM_LEVEL = 9 -function clampZoomLevel(value) { - if (!Number.isFinite(value)) return 0 +export function clampZoomLevel(value) { + if (!Number.isFinite(value)) {return 0} + return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) } -function zoomLevelToPercent(level) { +export function zoomLevelToPercent(level) { return Math.round(Math.pow(ZOOM_FACTOR_BASE, clampZoomLevel(level)) * 100) } -function percentToZoomLevel(percent) { - if (!Number.isFinite(percent) || percent <= 0) return 0 - return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE)) -} +export function percentToZoomLevel(percent) { + if (!Number.isFinite(percent) || percent <= 0) {return 0} -module.exports = { - ZOOM_STORAGE_KEY, - clampZoomLevel, - percentToZoomLevel, - zoomLevelToPercent -} + return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE)) +} \ No newline at end of file diff --git a/apps/desktop/eslint.config.mjs b/apps/desktop/eslint.config.mjs index 069a0056bbb9..ef098c5c8ab7 100644 --- a/apps/desktop/eslint.config.mjs +++ b/apps/desktop/eslint.config.mjs @@ -105,12 +105,12 @@ export default [ } }, { - files: ['**/*.js', '**/*.cjs'], + files: ['**/*.js', '**/*.cjs', '**/*.mjs'], ignores: ['**/node_modules/**', '**/dist/**'], languageOptions: { ecmaVersion: 'latest', globals: { ...globals.node }, - sourceType: 'commonjs' + sourceType: 'module' } }, { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c3daba00e2b3..2825f58ed9d2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -6,22 +6,22 @@ "description": "Native desktop shell for Hermes Agent.", "author": "Nous Research", "type": "module", - "main": "electron/main.cjs", + "main": "dist/electron-main.mjs", "engines": { "node": "^20.19.0 || >=22.12.0" }, "scripts": { "dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"", "dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev", - "dev:renderer": "node scripts/assert-root-install.cjs && vite --host 127.0.0.1 --port 5174", - "dev:electron": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", - "profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", - "profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", + "dev:renderer": "node scripts/assert-root-install.mjs && vite --host 127.0.0.1 --port 5174", + "dev:electron": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 NODE_OPTIONS=tsx electron electron/main.ts", + "profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 NODE_OPTIONS=tsx electron --inspect=9229 electron/main.ts", + "profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof NODE_OPTIONS=tsx HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron electron/main.ts", "start": "npm run build && electron .", - "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild", - "postbuild": "node scripts/assert-dist-built.cjs", - "prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs", - "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs", + "build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs", + "postbuild": "node scripts/assert-dist-built.mjs", + "prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs", + "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs", "pack": "npm run build && npm run builder -- --dir", "dist": "npm run build && npm run builder", "dist:mac": "npm run build && npm run builder -- --mac", @@ -37,14 +37,14 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/zoom.test.cjs electron/windows-hermes-resolution.test.cjs electron/oauth-session-request.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.ts electron/hardening.test.ts electron/backend-env.test.ts electron/backend-probes.test.ts electron/backend-ready.test.ts electron/bootstrap-runner.test.ts electron/connection-config.test.ts electron/dashboard-token.test.ts electron/gateway-ws-probe.test.ts electron/oauth-net-request.test.ts electron/desktop-uninstall.test.ts electron/session-windows.test.ts electron/link-title-window.test.ts electron/workspace-cwd.test.ts electron/fs-read-dir.test.ts electron/git-root.test.ts electron/git-worktree-ops.test.ts electron/windows-child-process.test.ts electron/update-remote.test.ts electron/update-count.test.ts electron/update-rebuild.test.ts electron/update-marker.test.ts electron/update-relaunch.test.ts electron/windows-user-env.test.ts electron/wsl-clipboard-image.test.ts electron/titlebar-overlay-width.test.ts electron/window-state.test.ts electron/zoom.test.ts electron/windows-hermes-resolution.test.ts electron/oauth-session-request.test.ts", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", - "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'", + "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'", "fix": "npm run lint:fix && npm run fmt", "test:ui": "vitest run --environment jsdom", - "preview": "node scripts/assert-root-install.cjs && vite preview --host 127.0.0.1 --port 4174" + "preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174" }, "dependencies": { "@assistant-ui/react": "^0.12.28", @@ -117,6 +117,7 @@ "web-haptics": "^0.0.6" }, "devDependencies": { + "@electron/rebuild": "^4.0.6", "@eslint/js": "^9.39.4", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.2", @@ -132,6 +133,7 @@ "cross-env": "^10.1.0", "electron": "40.10.2", "electron-builder": "^26.8.1", + "esbuild": "^0.28.1", "eslint": "^9.39.4", "eslint-plugin-perfectionist": "^5.9.0", "eslint-plugin-react": "^7.37.5", @@ -141,6 +143,7 @@ "jsdom": "^29.1.1", "prettier": "^3.8.3", "rcedit": "^5.0.2", + "tsx": "^4.22.4", "typescript": "^6.0.3", "vite": "^8.0.10", "vitest": "^4.1.5", @@ -167,29 +170,24 @@ "files": [ "dist/**", "assets/**", - "electron/**", "public/**", "package.json" ], - "beforeBuild": "scripts/before-build.cjs", - "beforePack": "scripts/before-pack.cjs", - "afterPack": "scripts/after-pack.cjs", + "beforeBuild": "scripts/before-build.mjs", + "beforePack": "scripts/before-pack.mjs", + "afterPack": "scripts/after-pack.mjs", "extraResources": [ { "from": "build/install-stamp.json", "to": "install-stamp.json" }, - { - "from": "build/native-deps", - "to": "native-deps" - }, { "from": "assets/icon.ico", "to": "icon.ico" } ], "asar": true, - "afterSign": "scripts/notarize.cjs", + "afterSign": "scripts/notarize.mjs", "asarUnpack": [ "**/*.node", "**/prebuilds/**", diff --git a/apps/desktop/scripts/after-pack.cjs b/apps/desktop/scripts/after-pack.mjs similarity index 81% rename from apps/desktop/scripts/after-pack.cjs rename to apps/desktop/scripts/after-pack.mjs index f81262d28ae6..509cbb424814 100644 --- a/apps/desktop/scripts/after-pack.cjs +++ b/apps/desktop/scripts/after-pack.mjs @@ -1,8 +1,8 @@ /** - * after-pack.cjs — electron-builder afterPack hook. + * after-pack.mjs — electron-builder afterPack hook. * * Stamps the Hermes icon + identity onto the packed Windows Hermes.exe via - * rcedit (delegated to set-exe-identity.cjs). This runs for EVERY packed build + * rcedit (delegated to set-exe-identity.mjs). This runs for EVERY packed build * — first install, `hermes desktop`, the installer's --update rebuild, and a * dev's manual `npm run pack` — so the branded exe can never silently revert * to the stock "Electron" icon/name (the bug when the stamp lived only in @@ -19,18 +19,18 @@ * - packager.appInfo.productFilename: the exe basename (e.g. 'Hermes') */ -const path = require('node:path') +import path from 'node:path' -const { stampExeIdentity } = require('./set-exe-identity.cjs') +import { stampExeIdentity } from './set-exe-identity.mjs' -exports.default = async function afterPack(context) { +export default async function afterPack(context) { if (context.electronPlatformName !== 'win32') { return } const productName = context.packager?.appInfo?.productFilename || 'Hermes' const exe = path.join(context.appOutDir, `${productName}.exe`) - const desktopRoot = path.resolve(__dirname, '..') + const desktopRoot = path.resolve(import.meta.dirname, '..') try { await stampExeIdentity(exe, desktopRoot) diff --git a/apps/desktop/scripts/assert-dist-built.cjs b/apps/desktop/scripts/assert-dist-built.mjs similarity index 74% rename from apps/desktop/scripts/assert-dist-built.cjs rename to apps/desktop/scripts/assert-dist-built.mjs index 8eea50f45a3e..d445edd6ea19 100644 --- a/apps/desktop/scripts/assert-dist-built.cjs +++ b/apps/desktop/scripts/assert-dist-built.mjs @@ -13,31 +13,32 @@ // inherits it. It fails loud and early instead of shipping a broken bundle. // See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404). -const fs = require("fs") -const path = require("path") +import { existsSync, statSync, readdirSync } from "fs" +import { join, resolve } from "path" +import { isMain } from "./utils.mjs" // Pure check — returns { ok: true } or { ok: false, error: "..." }. // Kept side-effect-free so it can be unit tested without spawning a process. -function checkDistBuilt(distDir) { - if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) { +export function checkDistBuilt(distDir) { + if (!existsSync(distDir) || !statSync(distDir).isDirectory()) { return { ok: false, error: `no dist directory at ${distDir}` } } - const indexHtml = path.join(distDir, "index.html") - if (!fs.existsSync(indexHtml) || !fs.statSync(indexHtml).isFile()) { + const indexHtml = join(distDir, "index.html") + if (!existsSync(indexHtml) || !statSync(indexHtml).isFile()) { return { ok: false, error: `dist/index.html is missing at ${indexHtml}` } } - if (fs.statSync(indexHtml).size === 0) { + if (statSync(indexHtml).size === 0) { return { ok: false, error: `dist/index.html is empty at ${indexHtml}` } } // index.html alone isn't enough — vite emits hashed JS into dist/assets. // An index.html with no script bundle still blank-pages. - const assetsDir = path.join(distDir, "assets") + const assetsDir = join(distDir, "assets") const hasAssets = - fs.existsSync(assetsDir) && - fs.statSync(assetsDir).isDirectory() && - fs.readdirSync(assetsDir).some(name => name.endsWith(".js")) + existsSync(assetsDir) && + statSync(assetsDir).isDirectory() && + readdirSync(assetsDir).some(name => name.endsWith(".js")) if (!hasAssets) { return { ok: false, error: `dist/assets has no built JS bundle (expected vite output under ${assetsDir})` } } @@ -46,8 +47,8 @@ function checkDistBuilt(distDir) { } function main() { - const desktopRoot = path.resolve(__dirname, "..") - const distDir = path.join(desktopRoot, "dist") + const desktopRoot = resolve(import.meta.dirname, "..") + const distDir = join(desktopRoot, "dist") const result = checkDistBuilt(distDir) if (!result.ok) { @@ -63,8 +64,8 @@ function main() { console.log("✓ assert-dist-built: dist/index.html + assets present") } -if (require.main === module) { +if (isMain(import.meta.url)) { main() } -module.exports = { checkDistBuilt } +export default { checkDistBuilt } diff --git a/apps/desktop/scripts/assert-dist-built.test.cjs b/apps/desktop/scripts/assert-dist-built.test.mjs similarity index 91% rename from apps/desktop/scripts/assert-dist-built.test.cjs rename to apps/desktop/scripts/assert-dist-built.test.mjs index 5121762469a8..7793d359962b 100644 --- a/apps/desktop/scripts/assert-dist-built.test.cjs +++ b/apps/desktop/scripts/assert-dist-built.test.mjs @@ -1,10 +1,10 @@ -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' -const { checkDistBuilt } = require('../scripts/assert-dist-built.cjs') +import { checkDistBuilt } from '../scripts/assert-dist-built.mjs' function makeDist(extra) { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-')) diff --git a/apps/desktop/scripts/assert-root-install.cjs b/apps/desktop/scripts/assert-root-install.cjs deleted file mode 100644 index 26433ca9be7e..000000000000 --- a/apps/desktop/scripts/assert-root-install.cjs +++ /dev/null @@ -1,13 +0,0 @@ -"use strict" - -const fs = require("fs") -const path = require("path") - -const root = path.resolve(__dirname, "..", "..", "..") - -try { - fs.accessSync(path.join(root, "node_modules", "vite", "package.json")) -} catch { - console.error(`Run from repo root: cd ${root} && npm ci`) - process.exit(1) -} diff --git a/apps/desktop/scripts/assert-root-install.mjs b/apps/desktop/scripts/assert-root-install.mjs new file mode 100644 index 000000000000..5dc1d51bdcd9 --- /dev/null +++ b/apps/desktop/scripts/assert-root-install.mjs @@ -0,0 +1,11 @@ +import { accessSync } from "fs" +import { resolve, join } from "path" + +const root = resolve(import.meta.dirname, "..", "..", "..") + +try { + accessSync(join(root, "node_modules", "vite", "package.json")) +} catch { + console.error(`Run from repo root: cd ${root} && npm ci`) + process.exit(1) +} diff --git a/apps/desktop/scripts/before-build.cjs b/apps/desktop/scripts/before-build.mjs similarity index 89% rename from apps/desktop/scripts/before-build.cjs rename to apps/desktop/scripts/before-build.mjs index 673aca380d37..e9a1d843ae58 100644 --- a/apps/desktop/scripts/before-build.cjs +++ b/apps/desktop/scripts/before-build.mjs @@ -4,8 +4,8 @@ * avoids workspace dependency graph explosions and keeps packaging * deterministic across environments. The Hermes Agent Python payload is no * longer bundled; the Electron app fetches it at first launch via - * `install.ps1`'s stage protocol (Windows). See `electron/main.cjs`. + * `install.ps1`'s stage protocol (Windows). See `electron/main.ts`. */ -module.exports = async function beforeBuild() { +export default async function beforeBuild() { return false } diff --git a/apps/desktop/scripts/before-pack.cjs b/apps/desktop/scripts/before-pack.mjs similarity index 54% rename from apps/desktop/scripts/before-pack.cjs rename to apps/desktop/scripts/before-pack.mjs index 7ef9bcfadc8a..a96b30651dd6 100644 --- a/apps/desktop/scripts/before-pack.cjs +++ b/apps/desktop/scripts/before-pack.mjs @@ -1,10 +1,11 @@ 'use strict' - /** - * before-pack.cjs — electron-builder beforePack hook. + * before-pack.mjs — electron-builder beforePack hook. * - * Removes any stale unpacked app directory (`appOutDir`) before - * electron-builder stages the Electron binaries into it. + * Two responsibilities: + * + * 1. Removes any stale unpacked app directory (`appOutDir`) before + * electron-builder stages the Electron binaries into it. * * WHY THIS EXISTS * --------------- @@ -41,30 +42,41 @@ * resolve rather than throw — worst case electron-builder hits the original * ENOENT, which is no worse than not having this hook at all. * + * 2. Re-stages node-pty's native files for the ACTUAL target platform/arch + * of this pack. `npm run build` already staged node-pty once for the + * host machine (see scripts/stage-native-deps.mjs), which is correct for + * single-arch builds matching the host. But electron-builder can target + * a different arch than the host (cross-build), or pack multiple archs + * from one `npm run build` (e.g. `dist:mac` => x64 + arm64). Only this + * hook knows the real per-target arch, via `context.arch` / + * `context.electronPlatformName` — so it re-stages on top of whatever + * `npm run build` left behind, per target, right before files are read + * for packing. + * * electron-builder passes a context with: * - appOutDir: the unpacked app directory about to be staged * - electronPlatformName: 'win32' | 'darwin' | 'linux' + * - arch: Arch enum (0=ia32, 1=x64, 2=armv7l, 3=arm64, 4=universal) */ +import { existsSync, rmSync } from 'node:fs' +import { Arch } from 'electron-builder' +import { stageNodePty } from './stage-native-deps.mjs' -const fs = require('node:fs') - -function cleanStaleAppOutDir(appOutDir) { +export function cleanStaleAppOutDir(appOutDir) { if (!appOutDir || typeof appOutDir !== 'string') { return false } - if (!fs.existsSync(appOutDir)) { + if (!existsSync(appOutDir)) { return false } // Recursive + force so a half-written tree (read-only bits, partial files) // can't block the wipe. retry/maxRetries rides out transient EBUSY on // Windows where an AV/indexer may briefly hold a handle. - fs.rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) return true } -exports.cleanStaleAppOutDir = cleanStaleAppOutDir - -exports.default = async function beforePack(context) { +export default async function beforePack(context) { const appOutDir = context && context.appOutDir try { if (cleanStaleAppOutDir(appOutDir)) { @@ -75,4 +87,26 @@ exports.default = async function beforePack(context) { // directory (permissions, mount) is still diagnosable. console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`) } -} + + try { + const platform = context && context.electronPlatformName + const archName = context && typeof context.arch === 'number' ? Arch[context.arch] : undefined + if (platform && archName) { + if (archName === 'universal') { + console.warn( + '[before-pack] target arch is "universal" — node-pty has no universal prebuild; ' + + 'staged binary will be whichever single-arch copy npm run build left behind. ' + + 'lipo-merge x64/arm64 .node files manually if you need a true universal build.' + ) + } else { + await stageNodePty({ platform, arch: archName }) + console.log(`[before-pack] re-staged node-pty for target ${platform}-${archName}`) + } + } + } catch (err) { + // This one SHOULD fail the build — a missing/wrong native binary for the + // target arch means a broken package shipped to users, which is worse + // than a build that fails loudly here. + throw new Error(`[before-pack] failed to stage node-pty for this target: ${err.message}`) + } +} \ No newline at end of file diff --git a/apps/desktop/scripts/before-pack.test.cjs b/apps/desktop/scripts/before-pack.test.mjs similarity index 86% rename from apps/desktop/scripts/before-pack.test.cjs rename to apps/desktop/scripts/before-pack.test.mjs index 763922aa6f8e..098121e0c6a0 100644 --- a/apps/desktop/scripts/before-pack.test.cjs +++ b/apps/desktop/scripts/before-pack.test.mjs @@ -1,10 +1,10 @@ -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' -const { cleanStaleAppOutDir } = require('../scripts/before-pack.cjs') +import beforePack, { cleanStaleAppOutDir } from '../scripts/before-pack.mjs' test('cleanStaleAppOutDir removes a populated unpacked directory', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) @@ -45,7 +45,6 @@ test('cleanStaleAppOutDir ignores empty or invalid input', () => { }) test('beforePack default export resolves even when cleanup throws', async () => { - const { default: beforePack } = require('../scripts/before-pack.cjs') // A directory path that rmSync can't remove is simulated by passing a // context whose appOutDir is a file the hook will try (and be allowed) to // remove; the contract under test is that the hook never rejects. diff --git a/apps/desktop/scripts/bundle-electron-main.mjs b/apps/desktop/scripts/bundle-electron-main.mjs new file mode 100644 index 000000000000..f950f4908a20 --- /dev/null +++ b/apps/desktop/scripts/bundle-electron-main.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +// bundle-electron-main.mjs — bundles electron/main.ts and electron/preload.ts +// into self-contained js files in dist/ so the packaged app doesn't need +// node_modules/ or tsx at runtime. +// +// Output: +// dist/electron-main.mjs (MJS bundle — entry point for packaged app) +// dist/electron-preload.js (CJS bundle — loaded via BrowserWindow preload) +// +// `electron` and `node-pty` are external (provided by the runtime / staged +// separately via stage-native-deps). +import { build } from 'esbuild' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { mkdirSync } from 'node:fs' + +const here = dirname(fileURLToPath(import.meta.url)) +const root = resolve(here, '..') +const distDir = resolve(root, 'dist') +mkdirSync(distDir, { recursive: true }) + +const mainEntry = resolve(root, 'electron/main.ts') +const mainOut = resolve(distDir, 'electron-main.mjs') +const preloadEntry = resolve(root, 'electron/preload.ts') +const preloadOut = resolve(distDir, 'electron-preload.js') + +const external = ['electron', 'node-pty', 'fs'] + const define = { + 'process.env.HERMES_DESKTOP_IS_PACKAGED': JSON.stringify(true) + } +// Bundle main.ts → dist/electron-main.mjs +await build({ + entryPoints: [mainEntry], + bundle: true, + platform: 'node', + format: 'esm', + target: 'node20', + outfile: mainOut, + external, + banner: { + js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);", + }, + define, + logLevel: 'info', +}) +console.log(`bundled ${mainOut}`) + +// Bundle preload.ts → dist/electron-preload.cjs +await build({ + entryPoints: [preloadEntry], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + outfile: preloadOut, + external, + define, + logLevel: 'info', +}) +console.log(`bundled ${preloadOut}`) diff --git a/apps/desktop/scripts/notarize-artifact.cjs b/apps/desktop/scripts/notarize-artifact.mjs similarity index 83% rename from apps/desktop/scripts/notarize-artifact.cjs rename to apps/desktop/scripts/notarize-artifact.mjs index 89a4901c5ccd..e7ea2f024ff2 100644 --- a/apps/desktop/scripts/notarize-artifact.cjs +++ b/apps/desktop/scripts/notarize-artifact.mjs @@ -1,7 +1,7 @@ -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { execFile } = require('node:child_process') +import { existsSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFile } from 'node:child_process' function run(command, args) { return new Promise((resolve, reject) => { @@ -26,7 +26,7 @@ function resolveApiKeyPath(rawValue) { const value = String(rawValue || '').trim() if (!value) return { keyPath: '', cleanup: () => {} } - if (fs.existsSync(value)) { + if (existsSync(value)) { return { keyPath: value, cleanup: () => {} } } @@ -34,17 +34,17 @@ function resolveApiKeyPath(rawValue) { throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content') } - const tempPath = path.join(os.tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`) - fs.writeFileSync(tempPath, value, 'utf8') + const tempPath = join(tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`) + writeFileSync(tempPath, value, 'utf8') return { keyPath: tempPath, - cleanup: () => fs.rmSync(tempPath, { force: true }) + cleanup: () => rmSync(tempPath, { force: true }) } } async function main() { const artifactPath = process.argv[2] - if (!artifactPath || !fs.existsSync(artifactPath)) { + if (!artifactPath || !existsSync(artifactPath)) { throw new Error(`Missing artifact to notarize: ${artifactPath || '(none)'}`) } diff --git a/apps/desktop/scripts/notarize.cjs b/apps/desktop/scripts/notarize.mjs similarity index 93% rename from apps/desktop/scripts/notarize.cjs rename to apps/desktop/scripts/notarize.mjs index 1508e18e8034..49294469e553 100644 --- a/apps/desktop/scripts/notarize.cjs +++ b/apps/desktop/scripts/notarize.mjs @@ -1,7 +1,7 @@ -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { execFile } = require('node:child_process') +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execFile } from 'node:child_process' function run(command, args) { return new Promise((resolve, reject) => { @@ -49,7 +49,7 @@ function resolveApiKeyPath(rawValue) { } } -exports.default = async function notarize(context) { +export default async function notarize(context) { const { electronPlatformName, appOutDir, packager } = context if (electronPlatformName !== 'darwin') return diff --git a/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs b/apps/desktop/scripts/patch-electron-builder-mac-binary.mjs similarity index 96% rename from apps/desktop/scripts/patch-electron-builder-mac-binary.cjs rename to apps/desktop/scripts/patch-electron-builder-mac-binary.mjs index b88c281219fe..6cfa41f32ec5 100644 --- a/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs +++ b/apps/desktop/scripts/patch-electron-builder-mac-binary.mjs @@ -1,11 +1,11 @@ -const fs = require('node:fs') -const path = require('node:path') +import fs from 'node:fs' +import path from 'node:path' if (process.platform !== 'darwin') { process.exit(0) } -const desktopRoot = path.resolve(__dirname, '..') +const desktopRoot = path.resolve(import.meta.dirname, '..') const repoRoot = path.resolve(desktopRoot, '..', '..') const electronMacPath = path.join(repoRoot, 'node_modules', 'app-builder-lib', 'out', 'electron', 'electronMac.js') diff --git a/apps/desktop/scripts/rebuild-native.mjs b/apps/desktop/scripts/rebuild-native.mjs new file mode 100644 index 000000000000..ddec5ea318e4 --- /dev/null +++ b/apps/desktop/scripts/rebuild-native.mjs @@ -0,0 +1,22 @@ +// rebuild-native.mjs +import { rebuild } from '@electron/rebuild' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { isMain } from './utils.mjs' +import packageJson from '../package.json' with { type: 'json' } +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +export async function rebuildNodePty({ arch = process.arch } = {}) { + await rebuild({ + buildPath: projectRoot, // where node_modules lives + electronVersion: packageJson.devDependencies.electron.replace('^', ''), + arch, + onlyModules: ['node-pty'], + force: true + }) +} + +if (isMain(import.meta.url)) { + const [arch] = process.argv.slice(2) + await rebuildNodePty({ arch }) +} diff --git a/apps/desktop/scripts/run-electron-builder.cjs b/apps/desktop/scripts/run-electron-builder.mjs similarity index 89% rename from apps/desktop/scripts/run-electron-builder.cjs rename to apps/desktop/scripts/run-electron-builder.mjs index 100d6c346e96..38e465612f9e 100644 --- a/apps/desktop/scripts/run-electron-builder.cjs +++ b/apps/desktop/scripts/run-electron-builder.mjs @@ -1,14 +1,15 @@ -"use strict" - // Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can // re-unpack a broken Electron.app; reusing the installed dist dodges that. // npm workspace hoisting is non-deterministic — require.resolve finds electron // wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let // electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR). -const fs = require("node:fs") -const path = require("node:path") -const { spawnSync } = require("node:child_process") +import fs from "node:fs" +import path from "node:path" +import { spawnSync } from "node:child_process" +import { createRequire } from "node:module" + +const require = createRequire(import.meta.url) function electronDistDir() { try { diff --git a/apps/desktop/scripts/set-exe-identity.cjs b/apps/desktop/scripts/set-exe-identity.mjs similarity index 70% rename from apps/desktop/scripts/set-exe-identity.cjs rename to apps/desktop/scripts/set-exe-identity.mjs index 129e1505bdab..4e19999c7546 100644 --- a/apps/desktop/scripts/set-exe-identity.cjs +++ b/apps/desktop/scripts/set-exe-identity.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// set-exe-identity.cjs — stamp the Hermes icon + version metadata onto the +// set-exe-identity.mjs — stamp the Hermes icon + version metadata onto the // built Hermes.exe using rcedit, completely decoupled from electron-builder's // signing path. // @@ -20,7 +20,7 @@ // // HOW IT RUNS // ----------- -// Primarily as an electron-builder `afterPack` hook (scripts/after-pack.cjs), +// Primarily as an electron-builder `afterPack` hook (scripts/after-pack.mjs), // so EVERY packed build — first install, `hermes desktop`, the installer's // --update rebuild, or a dev's manual `npm run pack` — gets a branded exe from // one place. Previously this stamp lived only in install.ps1, so the update @@ -28,40 +28,34 @@ // shipped a stock "Electron" exe. Keeping it in afterPack closes that gap. // // Also runnable standalone for ad-hoc re-stamping: -// node scripts/set-exe-identity.cjs <path-to-Hermes.exe> +// node scripts/set-exe-identity.mjs <path-to-Hermes.exe> // // Exits 0 on success, non-zero on failure when run as a CLI. As a hook, // stampExeIdentity() resolves on success and rejects on failure; the caller -// (after-pack.cjs) swallows the rejection so a stamp failure never fails an +// (after-pack.mjs) swallows the rejection so a stamp failure never fails an // otherwise-good build (worst case: stock icon, not a broken app). -const path = require('node:path') -const fs = require('node:fs') +import { resolve, join } from 'node:path' +import { existsSync } from 'node:fs' + +import { rcedit } from 'rcedit' + +import { isMain } from './utils.mjs' // Stamp the Hermes icon + identity onto `exe`. Resolves on success, throws on // failure. `desktopRoot` defaults to this script's package root so the icon and // the rcedit dependency resolve regardless of cwd. -async function stampExeIdentity(exe, desktopRoot = path.resolve(__dirname, '..')) { - if (!exe || !fs.existsSync(exe)) { +async function stampExeIdentity(exe, desktopRoot = resolve(import.meta.dirname, '..')) { + if (!exe || !existsSync(exe)) { throw new Error(`target exe not found: ${exe}`) } // Icon lives at apps/desktop/assets/icon.ico - const icon = path.join(desktopRoot, 'assets', 'icon.ico') - if (!fs.existsSync(icon)) { + const icon = join(desktopRoot, 'assets', 'icon.ico') + if (!existsSync(icon)) { throw new Error(`icon not found: ${icon}`) } - // rcedit is a direct devDependency of apps/desktop, so it resolves whether - // we're run from the desktop dir or the repo root (workspace hoist). - // rcedit@5 exports a NAMED `rcedit` function (CommonJS: { rcedit }), not a - // default export. - const mod = require('rcedit') - const rcedit = typeof mod === 'function' ? mod : mod.rcedit - if (typeof rcedit !== 'function') { - throw new Error(`unexpected rcedit export shape: ${typeof mod} keys=${Object.keys(mod)}`) - } - console.log(`[set-exe-identity] stamping ${exe}`) console.log(`[set-exe-identity] icon: ${icon}`) @@ -78,13 +72,13 @@ async function stampExeIdentity(exe, desktopRoot = path.resolve(__dirname, '..') console.log('[set-exe-identity] done — Hermes icon + identity stamped') } -module.exports = { stampExeIdentity } +export { stampExeIdentity } -// CLI entry point: `node scripts/set-exe-identity.cjs <exe>`. -if (require.main === module) { +// CLI entry point: `node scripts/set-exe-identity.mjs <exe>`. +if (isMain(import.meta.url)) { const exe = process.argv[2] if (!exe) { - console.error('[set-exe-identity] usage: set-exe-identity.cjs <path-to-exe>') + console.error('[set-exe-identity] usage: set-exe-identity.mjs <path-to-exe>') process.exit(2) } stampExeIdentity(exe).catch(err => { diff --git a/apps/desktop/scripts/stage-native-deps.cjs b/apps/desktop/scripts/stage-native-deps.cjs deleted file mode 100644 index ef68368dee76..000000000000 --- a/apps/desktop/scripts/stage-native-deps.cjs +++ /dev/null @@ -1,283 +0,0 @@ -'use strict' - -/** - * Stage native node-modules dependencies for electron-builder packaging. - * - * Workspace dedup hoists `node-pty` into the root `node_modules/`, which - * electron-builder's default file collector (when `files:` is explicitly set - * in package.json) cannot reach. The result: packaged builds ship with no - * .node binaries and PTY initialization fails at runtime ("PTY support is - * unavailable"). - * - * Rather than restructure the workspace dedup (would require nohoist / - * package.json shenanigans and risk breaking dev) or balloon the package - * with the whole node_modules tree, we copy ONLY the runtime-essential - * files of the native dep into apps/desktop/build/native-deps/ and ship - * THAT subtree via extraResources. main.cjs falls back to require()-ing - * from process.resourcesPath when the hoisted-root require fails. - * - * Runs as part of `npm run build`. Idempotent -- always re-stages on each - * build to pick up native binary updates. - * - * Layout note: upstream node-pty (microsoft/node-pty 1.x) is N-API based - * and ships its prebuilts under `prebuilds/<platform>-<arch>/` instead of - * `build/Release/`. Its runtime resolver (lib/utils.js) checks - * build/Release first and falls through to the per-arch prebuilds dir, so - * shipping only the latter is sufficient for packaged runs. Per-arch - * staging keeps the resource bundle lean -- we only need the target - * arch's prebuilt, not all of them. - */ - -const fs = require('node:fs') -const path = require('node:path') - -const APP_ROOT = path.resolve(__dirname, '..') -const REPO_ROOT = path.resolve(APP_ROOT, '..', '..') -const STAGE_ROOT = path.join(APP_ROOT, 'build', 'native-deps') - -// The target arch may be overridden by electron-builder via npm_config_arch -// (e.g. `npm run dist -- --arm64`); fall back to the build host's arch. -const TARGET_ARCH = process.env.npm_config_arch || process.arch -const TARGET_PLATFORM = process.platform - -// Modules to stage. The "from" path is the hoisted location in the workspace -// root; "to" is the layout we want inside build/native-deps/. The "include" -// globs (relative to "from") select the runtime-essential files. Anything -// outside the include list is left behind (source, deps/, scripts/, etc.). -const NATIVE_DEPS = [ - { - from: path.join(REPO_ROOT, 'node_modules', 'node-pty'), - to: path.join(STAGE_ROOT, 'node-pty'), - include: [ - 'package.json', - 'lib/*.js', - 'lib/**/*.js', - 'build/Release/*.node', - // Per-arch runtime payload. Explicit file types so we don't ship the - // ~25 MB of .pdb debug symbols that prebuild-install bundles for - // Windows crash analysis -- not used at runtime, would just bloat - // the installer. - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.node`, - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.dll`, - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.exe`, - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/spawn-helper`, - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/conpty/*` - ] - } -] - -// Pure-JS runtime dependencies that the packaged electron main require()s but -// that workspace dedup hoists into the repo-root node_modules -- out of reach -// of electron-builder's file collector, exactly like node-pty above. Unlike -// node-pty there is no native binary to select; we stage each package's whole -// directory into build/native-deps/vendor/node_modules/<name> so the dep's own -// internal require()s resolve against a real node_modules tree, and the -// requiring file (electron/git-review-ops.cjs) falls back to that path via -// process.resourcesPath when the normal require() fails. See issue #52735 -// (packaged app crashed at launch on `Cannot find module 'simple-git'`). -// -// The closure is resolved at stage time by walking dependencies + -// optionalDependencies, so a simple-git version bump that pulls in a new -// transitive dep can't silently re-introduce the crash. -// -// Layout note: the closure lands in build/native-deps/vendor/node_modules/, -// NOT build/native-deps/node_modules/. electron-builder's file collector -// hard-drops a `node_modules` directory that sits at the ROOT of an -// extraResources copy (app-builder-lib/out/util/filter.js: `if (relative === -// "node_modules") return false`), but keeps a NESTED one. Nesting under -// `vendor/` makes node_modules a subdirectory so it survives packing; the -// require() fallback in git-review-ops.cjs resolves the matching -// vendor/node_modules path. -const JS_DEP_ROOTS = ['simple-git'] -const JS_DEP_STAGE_ROOT = path.join(STAGE_ROOT, 'vendor', 'node_modules') - -function rmrf(target) { - fs.rmSync(target, { recursive: true, force: true }) -} - -function ensureDir(target) { - fs.mkdirSync(target, { recursive: true }) -} - -function walk(root) { - const results = [] - const stack = [root] - while (stack.length) { - const current = stack.pop() - let entries - try { - entries = fs.readdirSync(current, { withFileTypes: true }) - } catch { - continue - } - for (const entry of entries) { - const full = path.join(current, entry.name) - if (entry.isDirectory()) { - stack.push(full) - } else if (entry.isFile()) { - results.push(full) - } - } - } - return results -} - -// Match a relative path against simple ** and * glob patterns. Implementation -// is intentionally tiny -- the include lists are small and don't need full -// minimatch support. -function matchGlob(rel, pattern) { - const r = rel.replace(/\\/g, '/') - const re = new RegExp( - '^' + - pattern - .replace(/\\/g, '/') - .replace(/[.+^${}()|[\]\\]/g, '\\$&') - .replace(/\*\*/g, '__DOUBLE_STAR__') - .replace(/\*/g, '[^/]*') - .replace(/__DOUBLE_STAR__/g, '.*') + - '$' - ) - return re.test(r) -} - -function stageOne(spec) { - if (!fs.existsSync(spec.from)) { - throw new Error( - `stage-native-deps: source missing at ${spec.from}. Run \`npm install\` ` + - `at the workspace root first.` - ) - } - rmrf(spec.to) - ensureDir(spec.to) - - const files = walk(spec.from) - let copied = 0 - for (const abs of files) { - const rel = path.relative(spec.from, abs) - const included = spec.include.some(g => matchGlob(rel, g)) - if (!included) continue - const dest = path.join(spec.to, rel) - ensureDir(path.dirname(dest)) - fs.copyFileSync(abs, dest) - // node-pty's darwin spawn-helper and the Windows helper binaries - // (OpenConsole.exe, winpty-agent.exe) are invoked via posix_spawn / - // CreateProcess at runtime, so they must remain executable in the - // staged tree. fs.copyFileSync preserves source mode on POSIX, but we - // re-assert +x defensively for the darwin spawn-helper (no extension - // means a stripped mode would be silently broken at runtime). - if (path.basename(rel) === 'spawn-helper' && process.platform !== 'win32') { - try { fs.chmodSync(dest, 0o755) } catch { /* best-effort */ } - } - copied += 1 - } - console.log(`[stage-native-deps] ${path.relative(APP_ROOT, spec.to)}: ${copied} files`) -} - -// Resolve a package's directory by name, searching the repo-root node_modules -// first (where workspace dedup hoists everything) and then the requiring -// package's own node_modules for any non-hoisted nested copy. -// -// We deliberately do NOT use require.resolve(`${name}/package.json`): packages -// with an "exports" map that doesn't list "./package.json" (e.g. simple-git -// 3.x) make that subpath unresolvable under Node's exports enforcement -// (ERR_PACKAGE_PATH_NOT_EXPORTED), which fails on CI even though it happened to -// work locally. Instead resolve the package's main entry (exports-aware) and -// walk up to the directory whose package.json's "name" matches. -function resolvePkgDir(name, fromDir) { - const searchPaths = [fromDir, REPO_ROOT, path.join(REPO_ROOT, 'node_modules')] - let entry - try { - entry = require.resolve(name, { paths: searchPaths }) - } catch { - return null - } - // Walk up from the resolved entry file to the package root: the first - // ancestor dir whose package.json declares this package's name. - let dir = path.dirname(entry) - while (true) { - const pjPath = path.join(dir, 'package.json') - try { - const pj = JSON.parse(fs.readFileSync(pjPath, 'utf8')) - if (pj.name === name) { - return dir - } - } catch { - // no package.json here (or unreadable) — keep walking up - } - const parent = path.dirname(dir) - if (parent === dir) { - return null - } - dir = parent - } -} - -// Walk dependencies + optionalDependencies from each root package and return -// the set of resolved package directories in the runtime closure. Keyed by -// package name so a dep reached via two paths is staged once. -function resolveJsClosure(roots) { - const closure = new Map() // name -> absolute package dir - const stack = roots.map(name => ({ name, fromDir: REPO_ROOT })) - while (stack.length) { - const { name, fromDir } = stack.pop() - if (closure.has(name)) continue - const dir = resolvePkgDir(name, fromDir) - if (!dir) { - throw new Error( - `stage-native-deps: could not resolve '${name}' for the simple-git ` + - `closure. Run \`npm install\` at the workspace root first.` - ) - } - closure.set(name, dir) - let pj - try { - pj = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) - } catch { - continue - } - const deps = { ...(pj.dependencies || {}), ...(pj.optionalDependencies || {}) } - for (const depName of Object.keys(deps)) { - stack.push({ name: depName, fromDir: dir }) - } - } - return closure -} - -// Stage the resolved JS dependency closure into build/native-deps/vendor/node_modules/ -// so the packaged app (and the nix output) can require() it from -// process.resourcesPath when the hoisted-root require() isn't reachable. Each -// package is copied whole (minus node_modules/ — the closure is flattened so -// every dep already has its own top-level entry) into a real node_modules -// layout, which keeps the deps' own internal require()s working unchanged. -function stageJsClosure(roots) { - const closure = resolveJsClosure(roots) - rmrf(JS_DEP_STAGE_ROOT) - ensureDir(JS_DEP_STAGE_ROOT) - let staged = 0 - for (const [name, fromDir] of closure) { - const dest = path.join(JS_DEP_STAGE_ROOT, name) - ensureDir(path.dirname(dest)) - // Copy the package directory but skip any nested node_modules/ — the - // closure is flattened, so nested copies would just bloat the bundle. - fs.cpSync(fromDir, dest, { - recursive: true, - filter: src => path.basename(src) !== 'node_modules' - }) - staged += 1 - } - console.log( - `[stage-native-deps] vendor/node_modules/: ${staged} package(s) ` + - `(${[...closure.keys()].sort().join(', ')})` - ) -} - -function main() { - rmrf(STAGE_ROOT) - ensureDir(STAGE_ROOT) - for (const spec of NATIVE_DEPS) { - stageOne(spec) - } - stageJsClosure(JS_DEP_ROOTS) -} - -main() diff --git a/apps/desktop/scripts/stage-native-deps.mjs b/apps/desktop/scripts/stage-native-deps.mjs new file mode 100644 index 000000000000..ef590c6ecbf5 --- /dev/null +++ b/apps/desktop/scripts/stage-native-deps.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +// stage-native-deps.mjs — stages node-pty's native runtime dependencies +// +// Usage: +// node scripts/stage-native-deps.mjs # host platform/arch +// node scripts/stage-native-deps.mjs win32 arm64 # explicit target +// +// Also exported as `stageNodePty({ platform, arch })` for use from +// before-pack.mjs, where electron-builder gives you the real per-target +// platform/arch during multi-arch builds. + +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { dirname, resolve, join } from 'node:path' +import { + cpSync, + existsSync, + mkdirSync, + readdirSync, + rmSync +} from 'node:fs' +import { isMain } from './utils.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const projectRoot = resolve(here, '..') +const require = createRequire(import.meta.url) + +/** + * Locate node-pty's package root via real module resolution, so this + * works whether it's hoisted to a workspace root or local to this app. + */ +function resolveNodePtyRoot() { + const pkgJsonPath = require.resolve('node-pty/package.json', { + paths: [projectRoot] + }) + return dirname(pkgJsonPath) +} + +function copyGlobByExt(srcDir, destDir, extensions) { + if (!existsSync(srcDir)) return + mkdirSync(destDir, { recursive: true }) + for (const entry of readdirSync(srcDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + copyGlobByExt(join(srcDir, entry.name), join(destDir, entry.name), extensions) + continue + } + if (extensions.some((ext) => entry.name.endsWith(ext))) { + mkdirSync(destDir, { recursive: true }) + cpSync(join(srcDir, entry.name), join(destDir, entry.name)) + } + } +} + +/** + * Copies the locally-compiled build/Release output (used when no prebuild + * was available and node-pty was built from source for the host machine). + * + * Filters by name/pattern rather than extension only: macOS builds a + * separate `spawn-helper` executable (no file extension) that + * lib/unixTerminal.js requires at a fixed relative path. Filtering this + * directory by ['.node'] silently drops it — the package then looks + * fine, ships fine, and crashes the first time a terminal is spawned. + * Directories are copied wholesale to also cover any nested native + * payload (e.g. a conpty/ subfolder some build layouts produce). + */ +function copyBuildRelease(srcDir, destDir) { + if (!existsSync(srcDir)) return + mkdirSync(destDir, { recursive: true }) + for (const entry of readdirSync(srcDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + cpSync(join(srcDir, entry.name), join(destDir, entry.name), { recursive: true }) + continue + } + if (entry.name === 'spawn-helper' || /\.(node|dll|exe)$/.test(entry.name)) { + cpSync(join(srcDir, entry.name), join(destDir, entry.name)) + } + } +} + +export function stageNodePty({ platform = process.platform, arch = process.arch } = {}) { + const srcRoot = resolveNodePtyRoot() + const destRoot = resolve(projectRoot, 'dist/node_modules/node-pty') + + rmSync(destRoot, { recursive: true, force: true }) + mkdirSync(destRoot, { recursive: true }) + + // package.json — needed so `require('node-pty')` resolves the package + // (reads "main") rather than treating it as a directory with no entry. + cpSync(join(srcRoot, 'package.json'), join(destRoot, 'package.json')) + + // lib/**/*.js — the JS surface node-pty's `main` points into. + copyGlobByExt(join(srcRoot, 'lib'), join(destRoot, 'lib'), ['.js']) + + // build/Release/* — present when node-pty was compiled locally + // (e.g. no prebuild available for this Electron ABI/platform combo). + // Some installs won't have this at all if prebuild-install succeeded. + copyBuildRelease(join(srcRoot, 'build/Release'), join(destRoot, 'build/Release')) + + // prebuilds/<platform>-<arch>/* — the prebuild-install payload for the + // *target* we're packaging, not necessarily the host running this script. + // Explicit extensions only, to skip the ~25MB of Windows .pdb symbols + // prebuild-install bundles alongside the .node/.dll. + const prebuildDir = join(srcRoot, 'prebuilds', `${platform}-${arch}`) + if (existsSync(prebuildDir)) { + const destPrebuild = join(destRoot, 'prebuilds', `${platform}-${arch}`) + mkdirSync(destPrebuild, { recursive: true }) + for (const entry of readdirSync(prebuildDir, { withFileTypes: true })) { + if (entry.name === 'conpty' && entry.isDirectory()) { + cpSync(join(prebuildDir, 'conpty'), join(destPrebuild, 'conpty'), { recursive: true }) + continue + } + if (entry.isFile() && /\.(node|dll|exe)$/.test(entry.name)) { + cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name)) + continue + } + if (entry.name === 'spawn-helper') { + cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name)) + } + } + } else { + console.warn( + `[stage-native-deps] no prebuild found at prebuilds/${platform}-${arch} for node-pty. ` + + `If build/Release/* above is also empty, this target will fail at runtime. ` + + `Run "npx electron-rebuild -w node-pty" for this target, or check that ` + + `node-pty's published prebuilds cover ${platform}-${arch}.` + ) + } + + console.log(`[stage-native-deps] staged node-pty (${platform}-${arch}) -> ${destRoot}`) + return destRoot +} + +// Allow direct CLI invocation: node scripts/stage-native-deps.mjs [platform] [arch] +if (isMain(import.meta.url)) { + const [platform, arch] = process.argv.slice(2) + stageNodePty({ platform, arch }) +} diff --git a/apps/desktop/scripts/test-desktop.mjs b/apps/desktop/scripts/test-desktop.mjs index fdff1523f8f5..bbda8ec5ee4a 100644 --- a/apps/desktop/scripts/test-desktop.mjs +++ b/apps/desktop/scripts/test-desktop.mjs @@ -5,10 +5,11 @@ import { spawn, spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { listPackage } from '@electron/asar' -const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(DESKTOP_ROOT, 'package.json'), 'utf8')) +import PACKAGE_JSON from '../package.json' with { type: 'json' } + const MODE = process.argv[2] || 'help' const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64' +const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release') const PLATFORM = process.platform @@ -48,7 +49,7 @@ const APP = (() => { } })() -// Default HERMES_HOME for non-sandboxed runs -- matches main.cjs's +// Default HERMES_HOME for non-sandboxed runs -- matches main.ts's // resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere // it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own // HERMES_HOME and never touches this. @@ -83,17 +84,23 @@ function exists(target) { return fs.existsSync(target) } -// Match nodepty native binding location to what main.cjs's resolver fallback -// expects (apps/desktop/electron/main.cjs, packaged-build branch). Upstream -// node-pty 1.x is N-API based and ships per-arch prebuilts under -// prebuilds/<platform>-<arch>/ instead of build/Release/. We check the -// per-arch dir since that's what stage-native-deps actually copies. +// Match node-pty native binding location to what the bundled electron-main.cjs +// resolves at runtime. stage-native-deps.mjs stages node-pty into +// dist/node_modules/node-pty, and dist/** is asarUnpacked (see package.json +// build.asarUnpack), so in a packaged build it lands under +// resources/app.asar.unpacked/dist/node_modules/node-pty — reachable by a bare +// require('node-pty') from the bundle. Upstream node-pty 1.x is N-API based and +// ships per-arch prebuilts under prebuilds/<platform>-<arch>/; nix/local builds +// instead compile from source into build/Release/. The stage script copies +// whichever is present, so we accept either as the native payload. function expectedNativeDepPaths() { - const root = path.join(APP.resourcesPath, 'native-deps', 'node-pty') + const root = path.join(APP.resourcesPath, 'app.asar.unpacked', 'dist', 'node_modules', 'node-pty') const prebuildsDir = path.join(root, 'prebuilds', `${PLATFORM}-${ARCH}`) + const buildReleaseDir = path.join(root, 'build', 'Release') return { packageJson: path.join(root, 'package.json'), prebuildsDir, + buildReleaseDir, libIndex: path.join(root, 'lib', 'index.js') } } @@ -279,8 +286,8 @@ function launchFresh() { // - The Hermes Agent Python payload is NOT shipped (it's fetched at first // launch via install.ps1's stage protocol). // - install-stamp.json IS shipped in resources/ with a valid commit + branch. -// - native-deps/@homebridge/node-pty-prebuilt-multiarch/ IS shipped with -// the package.json + lib/ + at least one .node binary (the renderer's +// - node-pty IS shipped inside app.asar.unpacked/dist/node_modules/node-pty +// with package.json + lib/ + at least one .node binary (the renderer's // integrated terminal needs this; see Phase 1F.6). // - The renderer's dist/index.html is reachable (either unpacked or // inside app.asar). @@ -320,24 +327,35 @@ function validateBundle() { // Positive assertion: node-pty native deps shipped const native = expectedNativeDepPaths() if (!exists(native.packageJson)) { - die(`Missing node-pty package.json in resources/native-deps: ${native.packageJson}`) + die(`Missing node-pty package.json in app.asar.unpacked: ${native.packageJson}`) } if (!exists(native.libIndex)) { - die(`Missing node-pty lib/index.js in resources/native-deps: ${native.libIndex}`) + die(`Missing node-pty lib/index.js in app.asar.unpacked: ${native.libIndex}`) } - if (!exists(native.prebuildsDir)) { - die(`Missing node-pty prebuilds dir for ${PLATFORM}-${ARCH}: ${native.prebuildsDir}`) + // The native binary lands in prebuilds/<platform>-<arch>/ (downloaded prebuild) + // OR build/Release/ (compiled from source). stage-native-deps.mjs copies + // whichever is present, so accept either. + const nativeBinaryDirs = [native.prebuildsDir, native.buildReleaseDir].filter(exists) + if (nativeBinaryDirs.length === 0) { + die( + `Missing node-pty native binary dir for ${PLATFORM}-${ARCH}: neither ` + + `${native.prebuildsDir} nor ${native.buildReleaseDir} exists` + ) } - const nodeBinaries = fs.readdirSync(native.prebuildsDir).filter(name => name.endsWith('.node')) + const nodeBinaries = nativeBinaryDirs.flatMap(dir => + fs.readdirSync(dir).filter(name => name.endsWith('.node')) + ) if (nodeBinaries.length === 0) { - die(`No .node native binaries found in: ${native.prebuildsDir}`) + die(`No .node native binaries found in: ${nativeBinaryDirs.join(', ')}`) } // Darwin requires a runtime-execed spawn-helper alongside pty.node; missing // it manifests as "ENOENT: spawn-helper" on first pty.spawn() call. if (PLATFORM === 'darwin') { - const spawnHelper = path.join(native.prebuildsDir, 'spawn-helper') - if (!exists(spawnHelper)) { - die(`Missing node-pty spawn-helper (required on darwin): ${spawnHelper}`) + const spawnHelper = nativeBinaryDirs + .map(dir => path.join(dir, 'spawn-helper')) + .find(exists) + if (!spawnHelper) { + die(`Missing node-pty spawn-helper (required on darwin) in: ${nativeBinaryDirs.join(', ')}`) } } diff --git a/apps/desktop/scripts/utils.mjs b/apps/desktop/scripts/utils.mjs new file mode 100644 index 000000000000..7010213ec3cd --- /dev/null +++ b/apps/desktop/scripts/utils.mjs @@ -0,0 +1,8 @@ + +import { pathToFileURL } from 'node:url'; + +// returns true if the passsed file is being invoked from node, +// not imported. +export function isMain(importMetaUrl) { + return importMetaUrl === pathToFileURL(process.argv[1]).href; +} \ No newline at end of file diff --git a/apps/desktop/scripts/write-build-stamp.cjs b/apps/desktop/scripts/write-build-stamp.mjs similarity index 86% rename from apps/desktop/scripts/write-build-stamp.cjs rename to apps/desktop/scripts/write-build-stamp.mjs index 72b978c5f9a3..694bd9e8dd28 100644 --- a/apps/desktop/scripts/write-build-stamp.cjs +++ b/apps/desktop/scripts/write-build-stamp.mjs @@ -4,7 +4,7 @@ * Writes apps/desktop/build/install-stamp.json with the git ref the desktop * .exe should pin to at first-launch bootstrap time. This file ships inside * the packaged app via electron-builder's extraResources entry and is read - * by electron/main.cjs to drive the install.ps1 stage bootstrap flow. + * by electron/main.ts to drive the install.ps1 stage bootstrap flow. * * Schema (subject to bump via STAMP_SCHEMA_VERSION): * { @@ -26,16 +26,16 @@ * bootstrap without a stamp. */ -const fs = require("fs") -const path = require("path") -const { execSync } = require("child_process") +import { mkdirSync, writeFileSync } from "fs" +import { resolve, join, relative } from "path" +import { execSync } from "child_process" const STAMP_SCHEMA_VERSION = 1 -const DESKTOP_ROOT = path.resolve(__dirname, "..") -const REPO_ROOT = path.resolve(DESKTOP_ROOT, "..", "..") -const OUT_DIR = path.join(DESKTOP_ROOT, "build") -const OUT_FILE = path.join(OUT_DIR, "install-stamp.json") +const DESKTOP_ROOT = resolve(import.meta.dirname, "..") +const REPO_ROOT = resolve(DESKTOP_ROOT, "..", "..") +const OUT_DIR = join(DESKTOP_ROOT, "build") +const OUT_FILE = join(OUT_DIR, "install-stamp.json") function tryExec(cmd, opts) { try { @@ -111,11 +111,11 @@ function main() { source: stamp.source } - fs.mkdirSync(OUT_DIR, { recursive: true }) - fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8") + mkdirSync(OUT_DIR, { recursive: true }) + writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8") console.log( "[write-build-stamp] wrote " + - path.relative(REPO_ROOT, OUT_FILE) + + relative(REPO_ROOT, OUT_FILE) + " -> " + stamp.commit.slice(0, 12) + (stamp.branch ? " (" + stamp.branch + ")" : "") + diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 4b8c249bced6..cb7b10f655b7 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -847,7 +847,7 @@ export function DesktopController() { // window's gateway (the overlay has none) so it survives restart. setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale)) // Mail icon: $sessions is ordered most-recent-first; the pet is global (not - // per session) so "most recent" is the right target. main.cjs already raised + // per session) so "most recent" is the right target. main.ts already raised // the window before forwarding this. setPetOverlayOpenAppHandler(() => { const recent = $sessions.get()[0] diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index 5ce2ad8aa569..045a4e7975dc 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -1422,7 +1422,7 @@ describe('uploadComposerAttachment remote read failures', () => { }) it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => { - // electron/hardening.cjs rejects the readFileDataUrl IPC with this exact + // electron/hardening.ts rejects the readFileDataUrl IPC with this exact // shape when a file exceeds DATA_URL_READ_MAX_BYTES. Object.defineProperty(window, 'hermesDesktop', { configurable: true, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 76a25885854c..6b2049f892b8 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -21,9 +21,9 @@ import { _submitInFlight, type GatewayRequest, inlineErrorMessage, + isGatewayTimeoutError, isProviderSetupError, isSessionBusyError, - isGatewayTimeoutError, isSessionNotFoundError, type SubmitTextOptions, withSessionBusyRetry diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index 1df123824d1e..de501a52bbc7 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -142,7 +142,7 @@ export async function readFileDataUrlForAttach(filePath: string): Promise<string } // The readFileDataUrl IPC base64-loads the whole file into memory and is -// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which +// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.ts, which // rejects with a raw "file is too large (N bytes; limit M bytes)" string. In // remote mode every attachment's bytes go through that read, so a big file // surfaces that internal message verbatim in the failure toast. Translate it diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx index 7bcbc4bf84b9..f15bd79cdc2d 100644 --- a/apps/desktop/src/components/desktop-install-overlay.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -22,7 +22,7 @@ import { cn } from '@/lib/utils' * DesktopInstallOverlay * * Renders the first-launch install progress for Hermes Agent. Mounted always; - * shows itself only when main.cjs reports an in-flight bootstrap (state.active) + * shows itself only when main.ts reports an in-flight bootstrap (state.active) * OR an error from a completed-failed bootstrap (state.error). When the * bootstrap finishes successfully the overlay fades out and the rest of the * app (existing onboarding overlay -> main UI) takes over. @@ -32,7 +32,7 @@ import { cn } from '@/lib/utils' * - onBootstrapEvent(callback) -- live event stream * * The reducer is intentionally simple: every event mutates an in-component - * snapshot the same way main.cjs mutates its server-side snapshot. We don't + * snapshot the same way main.ts mutates its server-side snapshot. We don't * try to reconcile -- if we miss an event (shouldn't happen) the initial * getBootstrapState() call will resync the picture on the next render. * @@ -559,7 +559,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP </Button> <Button onClick={async () => { - // Tell main.cjs to clear its latched failure BEFORE we + // Tell main.ts to clear its latched failure BEFORE we // reload. Otherwise the renderer reload calls getConnection // and main short-circuits to the latched error without // re-running install.ps1. diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index 5ead9838dc79..280aa5d4f4be 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -220,7 +220,7 @@ export function FloatingPet() { }) // Wire the overlay control channel once, only in the primary window — the - // pop-out overlay belongs to it (main.cjs positions it against the main + // pop-out overlay belongs to it (main.ts positions it against the main // window and routes control messages back to it). useEffect(() => { if (isSecondaryWindow()) { diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index f9c5e34f5417..3c2e8fe8da21 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -459,7 +459,7 @@ export interface DesktopBootProgress { } // First-launch install ("bootstrap") event types -- emitted by -// electron/bootstrap-runner.cjs and observed by the renderer install overlay. +// electron/bootstrap-runner.ts and observed by the renderer install overlay. // Mirrors the event shapes emitted by runBootstrap()'s onEvent callback. export interface DesktopBootstrapStageDescriptor { diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 3bfd02e276fd..a902e4b53e1c 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -61,7 +61,7 @@ import type { // model info/options, cron) the moment the backend passes readiness. On a // profile-heavy or remote install these can each take tens of seconds — e.g. // /api/profiles runs list_profiles(), which does a recursive skill-tree walk -// per profile — so the 15s default (DEFAULT_FETCH_TIMEOUT_MS in hardening.cjs) +// per profile — so the 15s default (DEFAULT_FETCH_TIMEOUT_MS in hardening.ts) // times out a backend that is alive-but-busy, surfacing as a spurious // "Timed out connecting to Hermes backend" that hangs the UI (#48504). // diff --git a/apps/desktop/src/lib/media.ts b/apps/desktop/src/lib/media.ts index e8dfd35c7f6e..ffaea2c7c2bc 100644 --- a/apps/desktop/src/lib/media.ts +++ b/apps/desktop/src/lib/media.ts @@ -79,7 +79,7 @@ export function mediaExternalUrl(path: string): string { return /^file:/i.test(path) ? path : `file://${path}` } -// Custom Electron scheme (registered in electron/main.cjs) that streams a local +// Custom Electron scheme (registered in electron/main.ts) that streams a local // file with Range support. Used for audio/video so playback bypasses the data // URL size cap and supports seeking. `path` may be a plain path or `file://…`. export function mediaStreamUrl(path: string): string { diff --git a/apps/desktop/src/store/pet-overlay.ts b/apps/desktop/src/store/pet-overlay.ts index 1ab1b64ad232..cffa6b822b57 100644 --- a/apps/desktop/src/store/pet-overlay.ts +++ b/apps/desktop/src/store/pet-overlay.ts @@ -8,7 +8,7 @@ import { $awaitingResponse, $busy } from '@/store/session' * Controller for the pop-out pet overlay (main-renderer side). * * Shift-clicking the in-window pet "pops it out" into a transparent, - * always-on-top OS window (created in electron/main.cjs) that can leave the + * always-on-top OS window (created in electron/main.ts) that can leave the * app's bounds and stays visible while Hermes is minimized. That window carries * NO gateway connection — this renderer remains the single source of truth and * pushes the live pet state to it over IPC. Control flows back (pop the pet back @@ -30,7 +30,7 @@ export interface PetOverlayBounds { /** * Request to open the overlay window. `screen` says whether `bounds` are already * in absolute screen coordinates (a remembered/dragged spot) or in the main - * window's viewport space (a fresh shift-click pop-out, which main.cjs converts + * window's viewport space (a fresh shift-click pop-out, which main.ts converts * by adding the content origin). */ export interface PetOverlayOpenRequest { @@ -172,7 +172,7 @@ function openOverlay(request: PetOverlayOpenRequest): void { /** * Pop the pet out of the window. `petRect` is the in-window sprite's viewport * rect; we grow it to the padded overlay size and center the window on the - * pet's old spot (main.cjs adds the window's screen origin). If the user has + * pet's old spot (main.ts adds the window's screen origin). If the user has * popped out before, reopen at that remembered desktop spot instead. */ export function popOutPet(petRect: PetOverlayBounds): void { @@ -273,7 +273,7 @@ export function initPetOverlayBridge(): () => void { // overlay on the next push, keeping both surfaces in sync. scaleHandler?.(payload.scale) } else if (payload?.type === 'open-app') { - // Mail icon: surface the app on the most recent thread (main.cjs already + // Mail icon: surface the app on the most recent thread (main.ts already // focused the window before forwarding this) and mark it read. clearPetUnread() openAppHandler?.() diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts index c5b36ca68554..a881aa4794c6 100644 --- a/apps/desktop/src/store/windows.ts +++ b/apps/desktop/src/store/windows.ts @@ -1,7 +1,7 @@ import { notifyError } from './notifications' // Window flag set by the Electron main process when it opens a standalone -// session window (see electron/main.cjs buildSessionWindowUrl). It rides in the +// session window (see electron/main.ts buildSessionWindowUrl). It rides in the // query string BEFORE the HashRouter '#', so we read it from location.search, // never from the router. A "secondary" window renders a single chat without the // global session sidebar or the install / onboarding overlays. diff --git a/apps/desktop/src/store/zoom.ts b/apps/desktop/src/store/zoom.ts index 804f32c68a7b..6fc867c3dddf 100644 --- a/apps/desktop/src/store/zoom.ts +++ b/apps/desktop/src/store/zoom.ts @@ -1,7 +1,7 @@ /** * Window text size (zoom). * - * The main process owns the zoom level and persists it (see electron/zoom.cjs + * The main process owns the zoom level and persists it (see electron/zoom.ts * for the scale). The renderer only mirrors the current percent for the * settings UI: preset clicks go to the main process over IPC, and every * change comes back through onChanged, including ones made with the diff --git a/apps/desktop/src/themes/install.ts b/apps/desktop/src/themes/install.ts index 0958a92a99e1..933203e8dc6d 100644 --- a/apps/desktop/src/themes/install.ts +++ b/apps/desktop/src/themes/install.ts @@ -2,7 +2,7 @@ * Install desktop themes from external sources. * * The heavy lifting (network + .vsix unzip) lives in the Electron main process - * (`electron/vscode-marketplace.cjs`), reached via `window.hermesDesktop.themes`. + * (`electron/vscode-marketplace.ts`), reached via `window.hermesDesktop.themes`. * Main hands back the raw theme JSON; we parse + convert + persist here so the * conversion stays in one unit-testable place. */ diff --git a/apps/desktop/tsconfig.electron.json b/apps/desktop/tsconfig.electron.json new file mode 100644 index 000000000000..0d25682602b5 --- /dev/null +++ b/apps/desktop/tsconfig.electron.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "strict": false, + "noImplicitAny": false, + "noImplicitThis": false, + "strictNullChecks": false, + "strictFunctionTypes": false, + "strictBindCallApply": false, + "strictPropertyInitialization": false, + "noUncheckedIndexedAccess": false, + "exactOptionalPropertyTypes": false, + "ignoreDeprecations": "6.0", + "composite": true, + "declaration": true, + "outDir": "build/electron-types" + }, + "include": ["electron"], + "exclude": ["src"] +} diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 270dc9f126ce..41a4b25a8b96 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2023", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ES2023"], + "types": ["node"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, @@ -13,7 +14,6 @@ "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, - "noEmit": true, "jsx": "react-jsx", "paths": { "@/*": ["./src/*"], @@ -21,5 +21,5 @@ } }, "include": ["src", "../shared/src"], - "references": [] + "references": [{ "path": "./tsconfig.electron.json" }] } diff --git a/nix/desktop.nix b/nix/desktop.nix index a912ef0745fb..a266accaaa83 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -26,6 +26,30 @@ let packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/apps/desktop/package.json")); version = packageJson.version; + electronHeaders = pkgs.fetchurl { + url = "https://artifacts.electronjs.org/headers/dist/v${electron.version}/node-v${electron.version}-headers.tar.gz"; + sha256 = "sha256-zi/QMwRZ0+FwE9XTE+DiSIeJXAwxmLKEaBWD5W3pMOI="; + }; + + # node-pty ships no Electron-tagged prebuild we can trust to match this + # exact nixpkgs electron version, so it's always compiled from source + # against Electron's own headers (not whatever Node ran `npm`). + targetPlatform = + if stdenv.hostPlatform.isDarwin then + "darwin" + else if stdenv.hostPlatform.isLinux then + "linux" + else + throw "hermes-desktop: unsupported host platform for node-pty staging"; + + targetArch = + if stdenv.hostPlatform.isAarch64 then + "arm64" + else if stdenv.hostPlatform.isx86_64 then + "x64" + else + throw "hermes-desktop: unsupported host arch for node-pty staging"; + # Build the renderer (dist/ + electron/ + package.json). renderer = pkgs.buildNpmPackage ( npm @@ -37,33 +61,40 @@ let buildPhase = '' runHook preBuild - # write-build-stamp.cjs replacement. Packaged Electron reads this - # at first-launch to pin the install.ps1 git ref; informational in - # nix builds (the backend comes from the derivation directly). mkdir -p apps/desktop/build - echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > apps/desktop/build/install-stamp.json - # patch shebangs in node_modules/.bin so npm exec can find the - # nix-store equivalents of /usr/bin/env (which doesn't exist in the sandbox) patchShebangs . pushd apps/desktop - # stage node-pty native binaries into build/native-deps for the final nix output - npm rebuild node-pty --build-from-source - node scripts/stage-native-deps.cjs - + # typecheck :3 npm exec tsc -b + + # build the renderer bundle + # vite's emptyOutDir wipes dist/ on every run + # so it has to be first npm exec vite build - # simple-git is the electron main's external runtime dep. It is not - # bundled into main.cjs; instead the stage-native-deps.cjs call above - # copies its closure to apps/desktop/build/native-deps/vendor/node_modules/, - # which installPhase ships into $out/native-deps/ — the same path the - # packaged app uses. electron/git-review-ops.cjs resolves it from - # process.resourcesPath when the hoisted require() isn't reachable - # (see issue #52735). node-pty's prebuilt is staged the same way; - # electron is provided by the runtime. preload.cjs stays separate — - # Electron loads it via __dirname, not require(). + # build the electron bundle + node scripts/bundle-electron-main.mjs + + # Compile node-pty against Electron's actual ABI (the nixpkgs + # `electron` we ship). Headers come from a pinned fetchurl input + # since the sandbox has no network here, so node-gyp's + # normal --disturl download path can't run. + mkdir -p "$TMPDIR/electron-headers" + tar -xzf ${electronHeaders} -C "$TMPDIR/electron-headers" --strip-components=1 + + npm rebuild node-pty \ + --build-from-source \ + --runtime=electron \ + --target=${electron.version} \ + --nodedir="$TMPDIR/electron-headers" \ + --disturl="" \ + --offline + + # Target platform/arch come from stdenv.hostPlatform, not the + # build host's own process.platform/arch. + node scripts/stage-native-deps.mjs ${targetPlatform} ${targetArch} popd runHook postBuild @@ -76,9 +107,9 @@ let npm run postbuild - # validate staged node-pty native binary is present - STAGED_PTY_NODE="./build/native-deps/node-pty/build/Release/pty.node" - + # validate staged node-pty native binary is present. + STAGED_PTY_NODE="./dist/node_modules/node-pty/build/Release/pty.node" + if [ ! -f "$STAGED_PTY_NODE" ]; then echo "FATAL: Missing staged node-pty native binary at $STAGED_PTY_NODE" echo "node-pty must be compiled natively" @@ -94,15 +125,13 @@ let runHook preInstall mkdir -p $out # vite writes to apps/desktop/dist/ (we cd'd there in buildPhase). - # apps/desktop/build was created before the cd. electron/ is source. + # stage-native-deps.mjs stages node-pty into dist/node_modules/node-pty, + # so copying dist/ wholesale carries the native dep along with the + # esbuild bundle that require()s it. apps/desktop/build was created + # before the cd. cp -rn apps/desktop/dist $out/ - cp -rn apps/desktop/electron $out/ - # flatten native-deps and install-stamp.json to the root level, exactly like - # electron-builder's extraResources does ("from": "build/native-deps", "to": "native-deps") - # so main.cjs can find it at process.resourcesPath + '/native-deps/node-pty' - cp -rn apps/desktop/build/native-deps $out/ - cp -n apps/desktop/build/install-stamp.json $out/ + echo '{"schemaVersion":1,"commit":"nix-dummy-commit","branch":"nix","dirty":false,"source":"nix"}' > $out/install-stamp.json cp -n apps/desktop/package.json $out/ runHook postInstall @@ -130,14 +159,7 @@ stdenv.mkDerivation { # Standard nixpkgs pattern for electron-builder apps: patch process.resourcesPath # to point to the app's directory. In Nix, unpackaged electron defaults this # to the electron distribution's resources path, breaking extraResources lookups. - substituteInPlace $out/share/hermes-desktop/electron/main.cjs \ - --replace-fail "process.resourcesPath" "'$out/share/hermes-desktop'" - - # git-review-ops.cjs has the same process.resourcesPath fallback for its - # staged simple-git dep (native-deps/vendor/node_modules/), so it needs the same - # rewrite — otherwise the require() fallback resolves against the electron - # dist's resources path and fails to load simple-git (issue #52735). - substituteInPlace $out/share/hermes-desktop/electron/git-review-ops.cjs \ + substituteInPlace $out/share/hermes-desktop/dist/electron-main.mjs \ --replace-fail "process.resourcesPath" "'$out/share/hermes-desktop'" # Wrap the nixpkgs electron binary to launch our app. Set diff --git a/package-lock.json b/package-lock.json index 16a531bd876a..5cd95168bbb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -131,6 +131,7 @@ "web-haptics": "^0.0.6" }, "devDependencies": { + "@electron/rebuild": "^4.0.6", "@eslint/js": "^9.39.4", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.2", @@ -146,6 +147,7 @@ "cross-env": "^10.1.0", "electron": "40.10.2", "electron-builder": "^26.8.1", + "esbuild": "^0.28.1", "eslint": "^9.39.4", "eslint-plugin-perfectionist": "^5.9.0", "eslint-plugin-react": "^7.37.5", @@ -155,6 +157,7 @@ "jsdom": "^29.1.1", "prettier": "^3.8.3", "rcedit": "^5.0.2", + "tsx": "^4.22.4", "typescript": "^6.0.3", "vite": "^8.0.10", "vitest": "^4.1.5", @@ -207,25 +210,6 @@ } } }, - "apps/desktop/node_modules/electron": { - "version": "40.10.2", - "resolved": "https://registry.npmjs.org/electron/-/electron-40.10.2.tgz", - "integrity": "sha512-Xj3Hy0Imbu4g0gDIW55w/jJYz94nMO2JRSGYA3LyAn5SwaERCelgZrA21vfH+Bi//SWAWQXddHsMwCqauyMT8g==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^24.9.0", - "extract-zip": "^2.0.1" - }, - "bin": { - "electron": "cli.js" - }, - "engines": { - "node": ">= 12.20.55" - } - }, "apps/shared": { "name": "@hermes/shared", "version": "0.0.0", @@ -1676,9 +1660,9 @@ } }, "node_modules/@electron/rebuild": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.4.tgz", - "integrity": "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.6.tgz", + "integrity": "sha512-323ygp5Lz4DP2nVu54NLCx4n0TKsQ3OMDG3PPeOFjOTMDtSxGbCi7mQfWBc5C80pp+9awEsiHx9HR9DfAM/IEQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9619,6 +9603,25 @@ "node": ">=0.10.0" } }, + "node_modules/electron": { + "version": "40.10.2", + "resolved": "https://registry.npmjs.org/electron/-/electron-40.10.2.tgz", + "integrity": "sha512-Xj3Hy0Imbu4g0gDIW55w/jJYz94nMO2JRSGYA3LyAn5SwaERCelgZrA21vfH+Bi//SWAWQXddHsMwCqauyMT8g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^24.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, "node_modules/electron-builder": { "version": "26.15.3", "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 9712e293e42b..083f5842ba98 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -49,7 +49,7 @@ param( # * Hermes-Setup.exe (the signed Tauri bootstrap installer) passes # -IncludeDesktop so a user who installed via the GUI ends up # with a launchable desktop binary. - # * The Electron desktop's own bootstrap-runner.cjs runs install.ps1 + # * The Electron desktop's own bootstrap-runner.ts runs install.ps1 # from inside an already-launched Hermes.exe; if THAT recursively # built apps/desktop it would try to overwrite the live Hermes.exe # on disk and fail. The recursive path omits the flag. @@ -2095,13 +2095,13 @@ function Set-PathVariable { function Write-BootstrapMarker { # Writes $InstallDir\.hermes-bootstrap-complete which tells the Hermes - # desktop app (apps/desktop/electron/main.cjs) "install.ps1 ran + # desktop app (apps/desktop/electron/main.ts) "install.ps1 ran # successfully — DON'T trigger the legacy first-launch bootstrap # runner." # - # Schema mirrors what main.cjs's writeBootstrapMarker() / isBootstrap + # Schema mirrors what main.ts's writeBootstrapMarker() / isBootstrap # Complete() expect. Keep this in lockstep when either side changes: - # apps/desktop/electron/main.cjs lines 1199-1222 + # apps/desktop/electron/main.ts lines 1199-1222 # BOOTSTRAP_MARKER_SCHEMA_VERSION = 1 (line 187) # # Pinned commit/branch come from -Commit + -Branch flags (passed by @@ -2545,7 +2545,7 @@ function Get-ElectronDir { return (Join-Path $InstallDir 'node_modules\electron') } -# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.cjs). +# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.mjs). function Test-ElectronDist { param([string]$InstallDir) $electronDir = Get-ElectronDir -InstallDir $InstallDir @@ -2828,7 +2828,7 @@ function Install-Desktop { } # 3b. The Hermes icon + identity are stamped onto Hermes.exe by the - # electron-builder `afterPack` hook (apps/desktop/scripts/after-pack.cjs) + # electron-builder `afterPack` hook (apps/desktop/scripts/after-pack.mjs) # during `npm run pack` above — for every build, so the installer's # --update rebuild stays branded too. No separate stamp step needed here. # electron-builder's own rcedit step stays disabled (signAndEditExecutable diff --git a/scripts/install.sh b/scripts/install.sh index 4171ba3d788b..74318165dfc4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2687,7 +2687,7 @@ _electron_dir() { fi } -# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.cjs). +# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.mjs). _electron_dist_ok() { local install_dir="$1" local electron_dir diff --git a/tests/test_desktop_electron_pin.py b/tests/test_desktop_electron_pin.py index 2943dc9c9feb..ff9f1554e7ad 100644 --- a/tests/test_desktop_electron_pin.py +++ b/tests/test_desktop_electron_pin.py @@ -93,43 +93,4 @@ def test_lockfile_resolves_the_pinned_electron(): f"package-lock.json resolves electron to {sorted(set(resolved))}, " f"but the pin is {spec!r}; run `npm install --package-lock-only` so " "`npm ci` stays consistent." - ) - - -DESKTOP_DIR = REPO_ROOT / "apps" / "desktop" -ELECTRON_BUILDER_WRAPPER = DESKTOP_DIR / "scripts" / "run-electron-builder.cjs" - - -def test_no_static_electron_dist_that_can_drift(): - """build.electronDist must not be a static path — hoisting is non-deterministic.""" - assert "electronDist" not in _desktop_pkg().get("build", {}), ( - "build.electronDist is hardcoded again. npm hoisting is non-deterministic, " - "so a static path silently breaks packaging when the layout changes. Let " - "scripts/run-electron-builder.cjs resolve it dynamically instead." - ) - - -def test_builder_script_routes_through_dynamic_resolver(): - """npm run builder must invoke run-electron-builder.cjs, not bare electron-builder.""" - builder = _desktop_pkg().get("scripts", {}).get("builder", "") - assert "run-electron-builder.cjs" in builder, ( - f"the 'builder' script must run scripts/run-electron-builder.cjs, got " - f"{builder!r}" - ) - assert ELECTRON_BUILDER_WRAPPER.is_file(), ( - f"missing dynamic-resolver wrapper at {ELECTRON_BUILDER_WRAPPER}" - ) - - -def test_resolver_uses_node_module_resolution(): - """Wrapper must resolve electron via require.resolve and pass -c.electronDist.""" - src = ELECTRON_BUILDER_WRAPPER.read_text(encoding="utf-8") - assert 'require.resolve("electron/package.json")' in src, ( - "run-electron-builder.cjs must resolve electron via " - "require.resolve('electron/package.json') to stay hoist-proof." - ) - # And it must hand the resolved dist to electron-builder as an override. - assert "-c.electronDist=" in src, ( - "run-electron-builder.cjs must pass the resolved dist to electron-builder " - "via -c.electronDist." - ) + ) \ No newline at end of file From 7a65530fa5b3a1795c4e24944ef7a6e7c4ff98ac Mon Sep 17 00:00:00 2001 From: ethernet <arilotter@gmail.com> Date: Fri, 3 Jul 2026 19:41:21 -0400 Subject: [PATCH 270/610] fix(js): set @types/node to node 22, what's required in "engines" --- apps/desktop/package.json | 2 +- package-lock.json | 57 ++++++++++++++++++++++++++++++++++++--- ui-tui/package.json | 2 +- web/package.json | 2 +- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2825f58ed9d2..9bad2161cb01 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -123,7 +123,7 @@ "@testing-library/react": "^16.3.2", "@types/d3-force": "^3.0.10", "@types/hast": "^3.0.4", - "@types/node": "^24.13.2", + "@types/node": "^22.20.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.59.1", diff --git a/package-lock.json b/package-lock.json index 5cd95168bbb6..1c25cb2b1dbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -137,7 +137,7 @@ "@testing-library/react": "^16.3.2", "@types/d3-force": "^3.0.10", "@types/hast": "^3.0.4", - "@types/node": "^24.13.2", + "@types/node": "^22.20.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.59.1", @@ -210,6 +210,23 @@ } } }, + "apps/desktop/node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "apps/desktop/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "apps/shared": { "name": "@hermes/shared", "version": "0.0.0", @@ -19537,7 +19554,7 @@ }, "devDependencies": { "@eslint/js": "^9", - "@types/node": "^24.13.2", + "@types/node": "^22.20.0", "@types/react": "^19.2.14", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", @@ -19554,6 +19571,23 @@ "vitest": "^4.1.3" } }, + "ui-tui/node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "ui-tui/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "ui-tui/packages/hermes-ink": { "name": "@hermes/ink", "version": "0.0.1", @@ -19616,7 +19650,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", - "@types/node": "^24.13.2", + "@types/node": "^22.20.0", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -19676,6 +19710,16 @@ } } }, + "web/node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "web/node_modules/globals": { "version": "17.7.0", "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", @@ -19688,6 +19732,13 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "web/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" } } } diff --git a/ui-tui/package.json b/ui-tui/package.json index d0a59798fb73..dfdb03b55a03 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -27,7 +27,7 @@ }, "devDependencies": { "@eslint/js": "^9", - "@types/node": "^24.13.2", + "@types/node": "^22.20.0", "@types/react": "^19.2.14", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", diff --git a/web/package.json b/web/package.json index 0e9987b51b38..d211f68c2d77 100644 --- a/web/package.json +++ b/web/package.json @@ -38,7 +38,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", - "@types/node": "^24.13.2", + "@types/node": "^22.20.0", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", From 56a8e81d33a524f0ba0d68b6d54c8786ed283fb8 Mon Sep 17 00:00:00 2001 From: ethernet <arilotter@gmail.com> Date: Wed, 8 Jul 2026 18:54:56 -0400 Subject: [PATCH 271/610] cleanup(desktop): `npm run fix` for fmtting we should run this as part of merges at some point :) --- apps/desktop/electron/backend-command.ts | 4 +- apps/desktop/electron/backend-env.test.ts | 6 +- apps/desktop/electron/backend-env.ts | 22 +- apps/desktop/electron/backend-probes.ts | 17 +- apps/desktop/electron/backend-ready.test.ts | 6 +- apps/desktop/electron/backend-ready.ts | 37 +- .../electron/bootstrap-platform.test.ts | 6 +- apps/desktop/electron/bootstrap-platform.ts | 40 +- .../desktop/electron/bootstrap-runner.test.ts | 5 +- apps/desktop/electron/bootstrap-runner.ts | 74 +- .../electron/connection-config.test.ts | 6 +- apps/desktop/electron/connection-config.ts | 26 +- apps/desktop/electron/dashboard-token.test.ts | 6 +- apps/desktop/electron/dashboard-token.ts | 14 +- .../electron/desktop-uninstall.test.ts | 6 +- apps/desktop/electron/desktop-uninstall.ts | 26 +- apps/desktop/electron/fs-read-dir.ts | 4 +- apps/desktop/electron/gateway-ws-probe.ts | 57 +- apps/desktop/electron/git-review-ops.ts | 14 +- apps/desktop/electron/git-root.ts | 5 +- .../desktop/electron/git-worktree-ops.test.ts | 6 +- apps/desktop/electron/git-worktree-ops.ts | 6 +- apps/desktop/electron/hardening.test.ts | 6 +- apps/desktop/electron/hardening.ts | 36 +- .../electron/link-title-window.test.ts | 6 +- apps/desktop/electron/link-title-window.ts | 8 +- apps/desktop/electron/main.ts | 878 +++++++++++++----- apps/desktop/electron/oauth-net-request.ts | 3 +- apps/desktop/electron/session-windows.test.ts | 4 +- apps/desktop/electron/session-windows.ts | 6 +- .../electron/titlebar-overlay-width.test.ts | 7 +- .../electron/titlebar-overlay-width.ts | 5 +- apps/desktop/electron/update-count.ts | 4 +- apps/desktop/electron/update-marker.test.ts | 18 +- apps/desktop/electron/update-marker.ts | 19 +- apps/desktop/electron/update-relaunch.test.ts | 6 +- apps/desktop/electron/update-relaunch.ts | 58 +- apps/desktop/electron/update-remote.test.ts | 6 +- apps/desktop/electron/update-remote.ts | 18 +- apps/desktop/electron/vscode-marketplace.ts | 8 +- apps/desktop/electron/window-state.test.ts | 6 +- apps/desktop/electron/window-state.ts | 37 +- .../electron/windows-child-process.test.ts | 1 - apps/desktop/electron/windows-user-env.ts | 35 +- apps/desktop/electron/workspace-cwd.ts | 2 +- .../electron/wsl-clipboard-image.test.ts | 12 +- apps/desktop/electron/wsl-clipboard-image.ts | 22 +- apps/desktop/electron/zoom.ts | 10 +- apps/desktop/src/app/artifacts/index.tsx | 5 +- apps/desktop/src/app/skills/hub.tsx | 4 +- apps/desktop/src/app/skills/index.tsx | 10 +- .../desktop/src/components/ui/copy-button.tsx | 8 +- apps/desktop/src/i18n/zh-hant.ts | 3 +- apps/desktop/src/i18n/zh.ts | 3 +- 54 files changed, 1163 insertions(+), 484 deletions(-) diff --git a/apps/desktop/electron/backend-command.ts b/apps/desktop/electron/backend-command.ts index a3e2aaac6dc3..76ab03b08929 100644 --- a/apps/desktop/electron/backend-command.ts +++ b/apps/desktop/electron/backend-command.ts @@ -32,7 +32,9 @@ export function serveBackendArgs(profile?: string) { export function dashboardFallbackArgs(args) { const i = args.indexOf('serve') - if (i === -1) {return args.slice()} + if (i === -1) { + return args.slice() + } return [...args.slice(0, i), 'dashboard', '--no-open', ...args.slice(i + 1)] } diff --git a/apps/desktop/electron/backend-env.test.ts b/apps/desktop/electron/backend-env.test.ts index 627e5c604f18..461697752623 100644 --- a/apps/desktop/electron/backend-env.test.ts +++ b/apps/desktop/electron/backend-env.test.ts @@ -2,12 +2,14 @@ import assert from 'node:assert/strict' import path from 'node:path' import test from 'node:test' -import { appendUniquePathEntries, +import { + appendUniquePathEntries, buildDesktopBackendEnv, buildDesktopBackendPath, normalizeHermesHomeRoot, pathEnvKey, - POSIX_SANE_PATH_ENTRIES } from './backend-env' + POSIX_SANE_PATH_ENTRIES +} from './backend-env' test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => { const result = buildDesktopBackendPath({ diff --git a/apps/desktop/electron/backend-env.ts b/apps/desktop/electron/backend-env.ts index 391e5f0afd66..506575618b4b 100644 --- a/apps/desktop/electron/backend-env.ts +++ b/apps/desktop/electron/backend-env.ts @@ -23,7 +23,9 @@ function pathModuleForPlatform(platform = process.platform) { } function pathEnvKey(env = process.env, platform = process.platform) { - if (platform !== 'win32') {return 'PATH'} + if (platform !== 'win32') { + return 'PATH' + } return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH' } @@ -39,11 +41,15 @@ function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) { const ordered = [] for (const entry of entries) { - if (!entry) {continue} + if (!entry) { + continue + } const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter) for (const part of parts) { - if (!part || seen.has(part)) {continue} + if (!part || seen.has(part)) { + continue + } seen.add(part) ordered.push(part) } @@ -68,7 +74,9 @@ function buildDesktopBackendPath({ } function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) }: any = {}) { - if (!hermesHome) {return hermesHome} + if (!hermesHome) { + return hermesHome + } const resolved = pathModule.resolve(String(hermesHome)) const parent = pathModule.dirname(resolved) @@ -103,10 +111,12 @@ function buildDesktopBackendEnv({ } } -export { appendUniquePathEntries, +export { + appendUniquePathEntries, buildDesktopBackendEnv, buildDesktopBackendPath, delimiterForPlatform, normalizeHermesHomeRoot, pathEnvKey, - POSIX_SANE_PATH_ENTRIES } + POSIX_SANE_PATH_ENTRIES +} diff --git a/apps/desktop/electron/backend-probes.ts b/apps/desktop/electron/backend-probes.ts index c30764e35e3b..0617bddde701 100644 --- a/apps/desktop/electron/backend-probes.ts +++ b/apps/desktop/electron/backend-probes.ts @@ -65,8 +65,10 @@ function hermesRuntimeImportProbe() { * @param {object} [opts.env] - Additional environment for the probe. * @returns {boolean} */ -function canImportHermesCli(pythonPath: string, opts:{env?: Record<string, string>} = {}) { - if (!pythonPath) {return false} +function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, string> } = {}) { + if (!pythonPath) { + return false + } try { execFileSync(pythonPath, ['-c', hermesRuntimeImportProbe()], { @@ -102,8 +104,10 @@ function canImportHermesCli(pythonPath: string, opts:{env?: Record<string, strin * in resolveHermesBackend. * @returns {boolean} */ -function verifyHermesCli(hermesCommand: string, opts?: {shell?: boolean}) { - if (!hermesCommand) {return false} +function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) { + if (!hermesCommand) { + return false + } try { execFileSync(hermesCommand, ['--version'], { @@ -119,7 +123,4 @@ function verifyHermesCli(hermesCommand: string, opts?: {shell?: boolean}) { } } -export { canImportHermesCli, - hermesRuntimeImportProbe, - PROBE_TIMEOUT_MS, - verifyHermesCli } +export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, verifyHermesCli } diff --git a/apps/desktop/electron/backend-ready.test.ts b/apps/desktop/electron/backend-ready.test.ts index 17657123193c..40676f4c1eaa 100644 --- a/apps/desktop/electron/backend-ready.test.ts +++ b/apps/desktop/electron/backend-ready.test.ts @@ -18,13 +18,15 @@ import os from 'node:os' import path from 'node:path' import test from 'node:test' -import { DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS, +import { + DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS, MIN_PORT_ANNOUNCE_TIMEOUT_MS, readDashboardReadyFile, resolvePortAnnounceTimeoutMs, waitForDashboardPort, waitForDashboardPortAnnouncement, - waitForDashboardReadyFile } from './backend-ready' + waitForDashboardReadyFile +} from './backend-ready' type FakeChildProcess = EventEmitter & { stdout: EventEmitter diff --git a/apps/desktop/electron/backend-ready.ts b/apps/desktop/electron/backend-ready.ts index 0bbc6fadb8bf..78f395c59eaf 100644 --- a/apps/desktop/electron/backend-ready.ts +++ b/apps/desktop/electron/backend-ready.ts @@ -57,7 +57,9 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs()) let done = false function cleanup() { - if (done) {return} + if (done) { + return + } done = true clearTimeout(timer) child.stdout.off('data', onData) @@ -105,7 +107,9 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs()) } function readDashboardReadyFile(readyFile: fs.PathOrFileDescriptor) { - if (!readyFile) {return null} + if (!readyFile) { + return null + } try { const parsed = JSON.parse(fs.readFileSync(readyFile, 'utf8')) @@ -123,11 +127,15 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno let interval = null function cleanup() { - if (done) {return} + if (done) { + return + } done = true clearTimeout(timer) - if (interval) {clearInterval(interval)} + if (interval) { + clearInterval(interval) + } child.off('exit', onExit) child.off('error', onError) } @@ -160,15 +168,20 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno child.on('error', onError) interval = setInterval(check, 50) - if (typeof interval.unref === 'function') {interval.unref()} + if (typeof interval.unref === 'function') { + interval.unref() + } check() }) } -function waitForDashboardPortAnnouncement(child, options: { - readyFile?: fs.PathOrFileDescriptor, - timeoutMs?: number -} = {}) { +function waitForDashboardPortAnnouncement( + child, + options: { + readyFile?: fs.PathOrFileDescriptor + timeoutMs?: number + } = {} +) { const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs() if (options.readyFile) { @@ -178,10 +191,12 @@ function waitForDashboardPortAnnouncement(child, options: { return waitForDashboardPort(child, timeoutMs) } -export { DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS, +export { + DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS, MIN_PORT_ANNOUNCE_TIMEOUT_MS, readDashboardReadyFile, resolvePortAnnounceTimeoutMs, waitForDashboardPort, waitForDashboardPortAnnouncement, - waitForDashboardReadyFile } + waitForDashboardReadyFile +} diff --git a/apps/desktop/electron/bootstrap-platform.test.ts b/apps/desktop/electron/bootstrap-platform.test.ts index a4e2c242eae9..c4db7fd65b9c 100644 --- a/apps/desktop/electron/bootstrap-platform.test.ts +++ b/apps/desktop/electron/bootstrap-platform.test.ts @@ -1,10 +1,12 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { bundledRuntimeImportCheck, +import { + bundledRuntimeImportCheck, detectRemoteDisplay, isWindowsBinaryPathInWsl, - isWslEnvironment } from './bootstrap-platform' + isWslEnvironment +} from './bootstrap-platform' test('isWslEnvironment detects WSL2 env vars on linux', () => { assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true) diff --git a/apps/desktop/electron/bootstrap-platform.ts b/apps/desktop/electron/bootstrap-platform.ts index 810081747243..066616c496e2 100644 --- a/apps/desktop/electron/bootstrap-platform.ts +++ b/apps/desktop/electron/bootstrap-platform.ts @@ -1,9 +1,13 @@ import fs from 'node:fs' function isWslEnvironment(env = process.env, platform = process.platform, kernelRelease = null) { - if (platform !== 'linux') {return false} + if (platform !== 'linux') { + return false + } - if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) {return true} + if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) { + return true + } try { const release = kernelRelease ?? fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8') @@ -14,10 +18,15 @@ function isWslEnvironment(env = process.env, platform = process.platform, kernel } } -function isWindowsBinaryPathInWsl(filePath, options: {isWsl?: boolean, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform} = {}) { +function isWindowsBinaryPathInWsl( + filePath, + options: { isWsl?: boolean; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {} +) { const isWsl = options.isWsl ?? isWslEnvironment(options.env, options.platform) - if (!isWsl) {return false} + if (!isWsl) { + return false + } const normalized = String(filePath || '') .replace(/\\/g, '/') @@ -51,7 +60,7 @@ const GPU_OVERRIDE_OFF = new Set(['0', 'false', 'no', 'off']) * * Pure + dependency-free so it can be unit-tested and called before app ready. */ -function detectRemoteDisplay(options: {env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform} = {}) { +function detectRemoteDisplay(options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}) { const env = options.env ?? process.env const platform = options.platform ?? process.platform @@ -59,13 +68,19 @@ function detectRemoteDisplay(options: {env?: NodeJS.ProcessEnv, platform?: NodeJ .trim() .toLowerCase() - if (GPU_OVERRIDE_ON.has(override)) {return 'override (HERMES_DESKTOP_DISABLE_GPU)'} + if (GPU_OVERRIDE_ON.has(override)) { + return 'override (HERMES_DESKTOP_DISABLE_GPU)' + } - if (GPU_OVERRIDE_OFF.has(override)) {return null} + if (GPU_OVERRIDE_OFF.has(override)) { + return null + } // Launched from an SSH session → the display is X11-forwarded or otherwise // remote. Covers the common `ssh user@box` + GUI-forwarding case. - if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) {return 'ssh-session'} + if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) { + return 'ssh-session' + } if (platform === 'linux') { // X11 forwarding sets DISPLAY to "<host>:N" (e.g. "localhost:10.0"); a @@ -84,13 +99,12 @@ function detectRemoteDisplay(options: {env?: NodeJS.ProcessEnv, platform?: NodeJ // "Console". const sessionName = String(env.SESSIONNAME || '') - if (/^rdp-/i.test(sessionName)) {return `rdp (SESSIONNAME=${sessionName})`} + if (/^rdp-/i.test(sessionName)) { + return `rdp (SESSIONNAME=${sessionName})` + } } return null } -export { bundledRuntimeImportCheck, - detectRemoteDisplay, - isWindowsBinaryPathInWsl, - isWslEnvironment } +export { bundledRuntimeImportCheck, detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } diff --git a/apps/desktop/electron/bootstrap-runner.test.ts b/apps/desktop/electron/bootstrap-runner.test.ts index 8c3b3f3d0a66..a61cec77f59e 100644 --- a/apps/desktop/electron/bootstrap-runner.test.ts +++ b/apps/desktop/electron/bootstrap-runner.test.ts @@ -4,10 +4,7 @@ import os from 'node:os' import path from 'node:path' import test from 'node:test' -import { cachedScriptPath, - installedAgentInstallScript, - resolveInstallScript, - runBootstrap } from './bootstrap-runner' +import { cachedScriptPath, installedAgentInstallScript, resolveInstallScript, runBootstrap } from './bootstrap-runner' const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh' diff --git a/apps/desktop/electron/bootstrap-runner.ts b/apps/desktop/electron/bootstrap-runner.ts index 091e36fbed46..21a8cb4cd5db 100644 --- a/apps/desktop/electron/bootstrap-runner.ts +++ b/apps/desktop/electron/bootstrap-runner.ts @@ -70,7 +70,9 @@ function installScriptKind() { } function resolveLocalInstallScript(sourceRepoRoot) { - if (!sourceRepoRoot) {return null} + if (!sourceRepoRoot) { + return null + } const candidate = path.join(sourceRepoRoot, 'scripts', installScriptName()) try { @@ -91,7 +93,9 @@ function bootstrapCacheDir(hermesHome) { // the pinned commit can't be fetched from GitHub (e.g. a locally-built desktop // app stamped to an unpushed HEAD). function installedAgentInstallScript(hermesHome) { - if (!hermesHome) {return null} + if (!hermesHome) { + return null + } const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName()) try { @@ -300,7 +304,9 @@ function resolveWindowsPowerShell() { const candidate = powershellUnderRoot(root) try { - if (fs.statSync(candidate).isFile()) {return candidate} + if (fs.statSync(candidate).isFile()) { + return candidate + } } catch { void 0 } @@ -314,7 +320,9 @@ function resolveWindowsPowerShell() { const candidate = path.join(dir, exe) try { - if (fs.statSync(candidate).isFile()) {return candidate} + if (fs.statSync(candidate).isFile()) { + return candidate + } } catch { void 0 } @@ -379,7 +387,9 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme const line = stdoutBuf.slice(0, nl).replace(/\r$/, '') stdoutBuf = stdoutBuf.slice(nl + 1) - if (line) {emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })} + if (line) { + emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' }) + } } }) @@ -393,22 +403,32 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme const line = stderrBuf.slice(0, nl).replace(/\r$/, '') stderrBuf = stderrBuf.slice(nl + 1) - if (line) {emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })} + if (line) { + emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' }) + } } }) child.on('error', err => { - if (abortSignal) {abortSignal.removeEventListener('abort', onAbort)} + if (abortSignal) { + abortSignal.removeEventListener('abort', onAbort) + } reject(err) }) child.on('close', (code, signal) => { - if (abortSignal) {abortSignal.removeEventListener('abort', onAbort)} + if (abortSignal) { + abortSignal.removeEventListener('abort', onAbort) + } // Flush any trailing bytes - if (stdoutBuf) {emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' } as any)} + if (stdoutBuf) { + emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' } as any) + } - if (stderrBuf) {emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' } as any)} + if (stderrBuf) { + emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' } as any) + } resolve({ stdout, stderr, code, signal, killed } as any) }) }) @@ -459,7 +479,9 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome const line = stdoutBuf.slice(0, nl).replace(/\r$/, '') stdoutBuf = stdoutBuf.slice(nl + 1) - if (line) {emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })} + if (line) { + emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' }) + } } }) @@ -473,21 +495,31 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome const line = stderrBuf.slice(0, nl).replace(/\r$/, '') stderrBuf = stderrBuf.slice(nl + 1) - if (line) {emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })} + if (line) { + emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' }) + } } }) child.on('error', err => { - if (abortSignal) {abortSignal.removeEventListener('abort', onAbort)} + if (abortSignal) { + abortSignal.removeEventListener('abort', onAbort) + } reject(err) }) child.on('close', (code, signal) => { - if (abortSignal) {abortSignal.removeEventListener('abort', onAbort)} + if (abortSignal) { + abortSignal.removeEventListener('abort', onAbort) + } - if (stdoutBuf) {emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })} + if (stdoutBuf) { + emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' }) + } - if (stderrBuf) {emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })} + if (stderrBuf) { + emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' }) + } resolve({ stdout, stderr, code, signal, killed }) }) }) @@ -723,7 +755,9 @@ async function runBootstrap(opts) { } try { - if (typeof onEvent === 'function') {onEvent(ev)} + if (typeof onEvent === 'function') { + onEvent(ev) + } } catch (err) { // Don't let a subscriber bug crash the bootstrap runLog.stream.write(`emit error: ${err && err.message}\n`) @@ -812,10 +846,12 @@ async function runBootstrap(opts) { } } -export { cachedScriptPath, +export { + cachedScriptPath, installedAgentInstallScript, // Exposed for testability parseStageResult, resolveInstallScript, resolveLocalInstallScript, - runBootstrap } + runBootstrap +} diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 5def068793de..34492bbd53fe 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -13,7 +13,8 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { AT_COOKIE_VARIANTS, +import { + AT_COOKIE_VARIANTS, authModeFromStatus, buildGatewayWsUrl, buildGatewayWsUrlWithTicket, @@ -27,7 +28,8 @@ import { AT_COOKIE_VARIANTS, resolveAuthMode, resolveTestWsUrl, RT_COOKIE_VARIANTS, - tokenPreview } from './connection-config' + tokenPreview +} from './connection-config' // --- connectionScopeKey / normAuthMode --- diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index 6c7346a9bdf7..5d84972dfc1a 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -237,11 +237,17 @@ function authModeFromStatus(statusBody) { * Returns 'oauth' | 'token'. */ function resolveAuthMode(inputAuthMode, existingAuthMode) { - if (inputAuthMode === 'oauth') {return 'oauth'} + if (inputAuthMode === 'oauth') { + return 'oauth' + } - if (inputAuthMode === 'token') {return 'token'} + if (inputAuthMode === 'token') { + return 'token' + } - if (existingAuthMode === 'oauth') {return 'oauth'} + if (existingAuthMode === 'oauth') { + return 'oauth' + } return 'token' } @@ -258,7 +264,9 @@ function resolveAuthMode(inputAuthMode, existingAuthMode) { * need to know whether an unexpired access token is present right now. */ function cookiesHaveSession(cookies) { - if (!Array.isArray(cookies)) {return false} + if (!Array.isArray(cookies)) { + return false + } return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value) } @@ -277,12 +285,15 @@ function cookiesHaveSession(cookies) { * the RT is also dead/revoked). */ function cookiesHaveLiveSession(cookies) { - if (!Array.isArray(cookies)) {return false} + if (!Array.isArray(cookies)) { + return false + } return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name))) } -export { AT_COOKIE_VARIANTS, +export { + AT_COOKIE_VARIANTS, authModeFromStatus, buildGatewayWsUrl, buildGatewayWsUrlWithTicket, @@ -296,4 +307,5 @@ export { AT_COOKIE_VARIANTS, resolveAuthMode, resolveTestWsUrl, RT_COOKIE_VARIANTS, - tokenPreview } + tokenPreview +} diff --git a/apps/desktop/electron/dashboard-token.test.ts b/apps/desktop/electron/dashboard-token.test.ts index 84916dc2d400..d07a7e3b111e 100644 --- a/apps/desktop/electron/dashboard-token.test.ts +++ b/apps/desktop/electron/dashboard-token.test.ts @@ -8,12 +8,14 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { adoptServedDashboardToken, +import { + adoptServedDashboardToken, dashboardIndexUrl, extractInjectedDashboardToken, fetchPublicText, isForeignBackendToken, - resolveServedDashboardToken } from './dashboard-token' + resolveServedDashboardToken +} from './dashboard-token' test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => { const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>' diff --git a/apps/desktop/electron/dashboard-token.ts b/apps/desktop/electron/dashboard-token.ts index 4f4d3b298d44..42f214853434 100644 --- a/apps/desktop/electron/dashboard-token.ts +++ b/apps/desktop/electron/dashboard-token.ts @@ -28,7 +28,9 @@ async function fetchPublicText(url, options: any = {}) { const text = await res.text() - if (!res.ok) {throw new Error(`${res.status}: ${text || res.statusText}`)} + if (!res.ok) { + throw new Error(`${res.status}: ${text || res.statusText}`) + } return text } @@ -36,7 +38,9 @@ async function fetchPublicText(url, options: any = {}) { function extractInjectedDashboardToken(html) { const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || '')) - if (!match) {return null} + if (!match) { + return null + } try { return JSON.parse(match[1]) @@ -97,10 +101,12 @@ async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, labe return servedToken } -export { adoptServedDashboardToken, +export { + adoptServedDashboardToken, dashboardIndexUrl, DEFAULT_TOKEN_FETCH_TIMEOUT_MS, extractInjectedDashboardToken, fetchPublicText, isForeignBackendToken, - resolveServedDashboardToken } + resolveServedDashboardToken +} diff --git a/apps/desktop/electron/desktop-uninstall.test.ts b/apps/desktop/electron/desktop-uninstall.test.ts index 3a4cdbbfa457..258db6c0f3c8 100644 --- a/apps/desktop/electron/desktop-uninstall.test.ts +++ b/apps/desktop/electron/desktop-uninstall.test.ts @@ -12,14 +12,16 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { buildPosixCleanupScript, +import { + buildPosixCleanupScript, buildWindowsCleanupScript, modeRemovesAgent, modeRemovesUserData, resolveRemovableAppPath, shouldRemoveAppBundle, UNINSTALL_MODES, - uninstallArgsForMode } from './desktop-uninstall' + uninstallArgsForMode +} from './desktop-uninstall' // --- uninstallArgsForMode --- diff --git a/apps/desktop/electron/desktop-uninstall.ts b/apps/desktop/electron/desktop-uninstall.ts index 1254aed2d4dd..feebe6595141 100644 --- a/apps/desktop/electron/desktop-uninstall.ts +++ b/apps/desktop/electron/desktop-uninstall.ts @@ -69,7 +69,9 @@ function modeRemovesUserData(mode) { function resolveRemovableAppPath(execPath, platform, env: any = {}) { const exe = String(execPath || '') - if (!exe) {return null} + if (!exe) { + return null + } // Use the path flavor that matches the TARGET platform, not the host running // this code — so the Windows branch parses backslash paths correctly even @@ -82,7 +84,9 @@ function resolveRemovableAppPath(execPath, platform, env: any = {}) { const contents = p.dirname(macOsDir) // …/Contents const appBundle = p.dirname(contents) // …/Hermes.app - if (appBundle.endsWith('.app')) {return appBundle} + if (appBundle.endsWith('.app')) { + return appBundle + } return null } @@ -91,17 +95,23 @@ function resolveRemovableAppPath(execPath, platform, env: any = {}) { // NSIS per-user installs Hermes.exe directly in the install dir. const dir = p.dirname(exe) - if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) {return dir} + if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) { + return dir + } return null } // Linux: an AppImage exposes its own path via the APPIMAGE env var. - if (env.APPIMAGE) {return env.APPIMAGE} + if (env.APPIMAGE) { + return env.APPIMAGE + } // Unpacked electron-builder tree: …/linux-unpacked/hermes const dir = p.dirname(exe) - if (/-unpacked$/.test(dir)) {return dir} + if (/-unpacked$/.test(dir)) { + return dir + } return null } @@ -245,11 +255,13 @@ function buildWindowsCleanupScript({ return lines.join('\r\n') } -export { buildPosixCleanupScript, +export { + buildPosixCleanupScript, buildWindowsCleanupScript, modeRemovesAgent, modeRemovesUserData, resolveRemovableAppPath, shouldRemoveAppBundle, UNINSTALL_MODES, - uninstallArgsForMode } + uninstallArgsForMode +} diff --git a/apps/desktop/electron/fs-read-dir.ts b/apps/desktop/electron/fs-read-dir.ts index 27b842ea3ffc..7d09650c81f0 100644 --- a/apps/desktop/electron/fs-read-dir.ts +++ b/apps/desktop/electron/fs-read-dir.ts @@ -36,7 +36,9 @@ function direntIsSymbolicLink(dirent) { } function shouldStatDirent(dirent) { - if (direntIsDirectory(dirent)) {return false} + if (direntIsDirectory(dirent)) { + return false + } return direntIsSymbolicLink(dirent) || !direntIsFile(dirent) } diff --git a/apps/desktop/electron/gateway-ws-probe.ts b/apps/desktop/electron/gateway-ws-probe.ts index 7202547e7e8a..9ed146714102 100644 --- a/apps/desktop/electron/gateway-ws-probe.ts +++ b/apps/desktop/electron/gateway-ws-probe.ts @@ -38,11 +38,14 @@ const DEFAULT_READY_GRACE_MS = 750 * @param {string} wsUrl - Fully-formed ws(s):// URL including the credential. * @returns {Promise<{ ok: boolean, reason?: string }>} */ -function probeGatewayWebSocket<T>(wsUrl: string, options:{ - WebSocketImpl?: any, - connectTimeoutMs?: number - readyGraceMs?: number -} = {}) { +function probeGatewayWebSocket<T>( + wsUrl: string, + options: { + WebSocketImpl?: any + connectTimeoutMs?: number + readyGraceMs?: number + } = {} +) { const WebSocketImpl = options.WebSocketImpl const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS @@ -74,7 +77,9 @@ function probeGatewayWebSocket<T>(wsUrl: string, options:{ } const finish = result => { - if (settled) {return} + if (settled) { + return + } settled = true clearTimers() @@ -99,7 +104,9 @@ function probeGatewayWebSocket<T>(wsUrl: string, options:{ } const onOpen = () => { - if (settled) {return} + if (settled) { + return + } opened = true // Upgrade accepted. Give the server a brief window to reject the // credential post-handshake (early close) before declaring success. @@ -122,7 +129,9 @@ function probeGatewayWebSocket<T>(wsUrl: string, options:{ } const onClose = event => { - if (settled) {return} + if (settled) { + return + } if (opened) { // Opened, then closed inside the grace window: the upgrade was accepted @@ -173,14 +182,22 @@ function addListener(socket, type, handler) { } function extractErrorReason(event) { - if (!event) {return ''} + if (!event) { + return '' + } - if (event instanceof Error) {return event.message} + if (event instanceof Error) { + return event.message + } const err = event.error || event.message - if (err instanceof Error) {return err.message} + if (err instanceof Error) { + return err.message + } - if (typeof err === 'string') {return err} + if (typeof err === 'string') { + return err + } return '' } @@ -189,15 +206,19 @@ function closeReason(event, fallback) { const code = event && typeof event.code === 'number' ? event.code : null const reason = event && typeof event.reason === 'string' ? event.reason.trim() : '' - if (code && reason) {return `${fallback} (code ${code}: ${reason})`} + if (code && reason) { + return `${fallback} (code ${code}: ${reason})` + } - if (code) {return `${fallback} (code ${code})`} + if (code) { + return `${fallback} (code ${code})` + } - if (reason) {return `${fallback} (${reason})`} + if (reason) { + return `${fallback} (${reason})` + } return fallback } -export { DEFAULT_CONNECT_TIMEOUT_MS, - DEFAULT_READY_GRACE_MS, - probeGatewayWebSocket } +export { DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READY_GRACE_MS, probeGatewayWebSocket } diff --git a/apps/desktop/electron/git-review-ops.ts b/apps/desktop/electron/git-review-ops.ts index cc4f4f3d3c83..1baf26d2fca3 100644 --- a/apps/desktop/electron/git-review-ops.ts +++ b/apps/desktop/electron/git-review-ops.ts @@ -8,7 +8,7 @@ import { execFile } from 'node:child_process' import fs from 'node:fs/promises' import path from 'node:path' -import simpleGit from "simple-git"; +import simpleGit from 'simple-git' import { resolveRequestedPathForIpc } from './hardening' @@ -31,7 +31,7 @@ function ghEnv(ghBin) { // Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on // availability/auth without a throw. gh missing/unauthed → ok:false. -function runGh(args, cwd, ghBin): Promise<{ok: boolean, stdout: string}> { +function runGh(args, cwd, ghBin): Promise<{ ok: boolean; stdout: string }> { return new Promise(resolve => { execFile( ghBin || 'gh', @@ -242,8 +242,8 @@ async function reviewList(repoPath, scope, baseRef, gitBin) { const files = summary.files.map(file => ({ path: resolveRenamePath(file.file), - added: 'insertions' in file ? file.insertions : 0 , - removed: 'deletions' in file ? file.deletions : 0 , + added: 'insertions' in file ? file.insertions : 0, + removed: 'deletions' in file ? file.deletions : 0, status: 'M', staged: false })) @@ -674,7 +674,8 @@ async function repoStatus(repoPath, gitBin) { return result } -export { branchBase, +export { + branchBase, fileDiffVsHead, repoStatus, resolveRenamePath, @@ -688,4 +689,5 @@ export { branchBase, reviewRevParse, reviewShipInfo, reviewStage, - reviewUnstage } + reviewUnstage +} diff --git a/apps/desktop/electron/git-root.ts b/apps/desktop/electron/git-root.ts index 93ea25988025..bd19c25a8fd3 100644 --- a/apps/desktop/electron/git-root.ts +++ b/apps/desktop/electron/git-root.ts @@ -27,7 +27,7 @@ function findGitRoot(start, fsImpl = fs) { return null } -async function gitRootForIpc(startPath, options: {fs?: typeof fs} = {}) { +async function gitRootForIpc(startPath, options: { fs?: typeof fs } = {}) { const fsImpl = options.fs || fs let resolved @@ -47,5 +47,4 @@ async function gitRootForIpc(startPath, options: {fs?: typeof fs} = {}) { } } -export { findGitRoot, - gitRootForIpc } +export { findGitRoot, gitRootForIpc } diff --git a/apps/desktop/electron/git-worktree-ops.test.ts b/apps/desktop/electron/git-worktree-ops.test.ts index 65e59c2d29d7..2d4617c254ca 100644 --- a/apps/desktop/electron/git-worktree-ops.test.ts +++ b/apps/desktop/electron/git-worktree-ops.test.ts @@ -5,12 +5,14 @@ import os from 'node:os' import path from 'node:path' import test from 'node:test' -import { addWorktree, +import { + addWorktree, ensureGitRepo, listBranches, parseWorktrees, sanitizeBranch, - switchBranch } from './git-worktree-ops' + switchBranch +} from './git-worktree-ops' test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => { assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes') diff --git a/apps/desktop/electron/git-worktree-ops.ts b/apps/desktop/electron/git-worktree-ops.ts index 93efaf13c4eb..61d37bbe6f69 100644 --- a/apps/desktop/electron/git-worktree-ops.ts +++ b/apps/desktop/electron/git-worktree-ops.ts @@ -337,11 +337,13 @@ async function switchBranch(repoPath, branch, gitBin) { return { branch: target } } -export { addWorktree, +export { + addWorktree, ensureGitRepo, listBranches, listWorktrees, parseWorktrees, removeWorktree, sanitizeBranch, - switchBranch } + switchBranch +} diff --git a/apps/desktop/electron/hardening.test.ts b/apps/desktop/electron/hardening.test.ts index 324e511ac3e9..5d0dc5e21230 100644 --- a/apps/desktop/electron/hardening.test.ts +++ b/apps/desktop/electron/hardening.test.ts @@ -5,13 +5,15 @@ import path from 'node:path' import test from 'node:test' import { pathToFileURL } from 'node:url' -import { DEFAULT_FETCH_TIMEOUT_MS, +import { + DEFAULT_FETCH_TIMEOUT_MS, encryptDesktopSecret, resolveDirectoryForIpc, resolveReadableFileForIpc, resolveRequestedPathForIpc, resolveTimeoutMs, - sensitiveFileBlockReason } from './hardening' + sensitiveFileBlockReason +} from './hardening' async function rejectsWithCode(promise, code: string) { await assert.rejects(promise, (error: any) => { diff --git a/apps/desktop/electron/hardening.ts b/apps/desktop/electron/hardening.ts index b344f5960400..5fb113229b38 100644 --- a/apps/desktop/electron/hardening.ts +++ b/apps/desktop/electron/hardening.ts @@ -110,9 +110,9 @@ function sensitiveFileBlockReason(filePath) { return null } -function ipcPathError(code: any, message: string): Error & {code: any} { - const error = new Error(message) as Error & {code: any} - (error as any).code = code +function ipcPathError(code: any, message: string): Error & { code: any } { + const error = new Error(message) as Error & { code: any } + ;(error as any).code = code return error } @@ -146,7 +146,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') { return raw } -function resolveRequestedPathForIpc(filePath, options: {purpose?: string, baseDir?: fs.PathOrFileDescriptor} = {}) { +function resolveRequestedPathForIpc(filePath, options: { purpose?: string; baseDir?: fs.PathOrFileDescriptor } = {}) { const purpose = String(options.purpose || 'File read') let raw = rejectUnsafePathSyntax(filePath, purpose) @@ -187,7 +187,7 @@ function resolveRequestedPathForIpc(filePath, options: {purpose?: string, baseDi return resolvedPath } -async function statForIpc(fsImpl: {promises: {stat: typeof fs.promises.stat}}, resolvedPath, purpose, typeLabel) { +async function statForIpc(fsImpl: { promises: { stat: typeof fs.promises.stat } }, resolvedPath, purpose, typeLabel) { try { return await fsImpl.promises.stat(resolvedPath) } catch (error) { @@ -231,7 +231,14 @@ function rejectSensitiveFilePath(filePath, purpose) { } } -async function resolveDirectoryForIpc(dirPath, options: {purpose?: string , baseDir?: fs.PathOrFileDescriptor, fs?: {promises:{stat: typeof fs.promises.stat}}} = {}) { +async function resolveDirectoryForIpc( + dirPath, + options: { + purpose?: string + baseDir?: fs.PathOrFileDescriptor + fs?: { promises: { stat: typeof fs.promises.stat } } + } = {} +) { const purpose = String(options.purpose || 'Directory read') const fsImpl = options.fs || fs const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose }) @@ -246,7 +253,16 @@ async function resolveDirectoryForIpc(dirPath, options: {purpose?: string , base return { realPath, resolvedPath, stat } } -async function resolveReadableFileForIpc(filePath, options: {purpose?: string , baseDir?: fs.PathOrFileDescriptor, fs?: typeof fs, blockSensitive?: boolean, maxBytes?: number} = {}) { +async function resolveReadableFileForIpc( + filePath, + options: { + purpose?: string + baseDir?: fs.PathOrFileDescriptor + fs?: typeof fs + blockSensitive?: boolean + maxBytes?: number + } = {} +) { const purpose = String(options.purpose || 'File read') const fsImpl = options.fs || fs const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose }) @@ -286,7 +302,8 @@ async function resolveReadableFileForIpc(filePath, options: {purpose?: string , return { realPath, resolvedPath, stat } } -export { DATA_URL_READ_MAX_BYTES, +export { + DATA_URL_READ_MAX_BYTES, DEFAULT_FETCH_TIMEOUT_MS, encryptDesktopSecret, rejectUnsafePathSyntax, @@ -295,4 +312,5 @@ export { DATA_URL_READ_MAX_BYTES, resolveRequestedPathForIpc, resolveTimeoutMs, sensitiveFileBlockReason, - TEXT_PREVIEW_SOURCE_MAX_BYTES } + TEXT_PREVIEW_SOURCE_MAX_BYTES +} diff --git a/apps/desktop/electron/link-title-window.test.ts b/apps/desktop/electron/link-title-window.test.ts index e1b1cc5794f0..d67b3eb94ca7 100644 --- a/apps/desktop/electron/link-title-window.test.ts +++ b/apps/desktop/electron/link-title-window.test.ts @@ -1,10 +1,12 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { createLinkTitleWindow, +import { + createLinkTitleWindow, guardLinkTitleSession, linkTitleWindowOptions, - readLinkTitleWindowTitle } from './link-title-window' + readLinkTitleWindowTitle +} from './link-title-window' function makeFakeBrowserWindow() { const calls = { audioMuted: [] } diff --git a/apps/desktop/electron/link-title-window.ts b/apps/desktop/electron/link-title-window.ts index 9e67e4b14157..f636c4b07349 100644 --- a/apps/desktop/electron/link-title-window.ts +++ b/apps/desktop/electron/link-title-window.ts @@ -52,10 +52,14 @@ export function guardLinkTitleSession(partitionSession) { // guard isDestroyed and swallow Electron's "Object has been destroyed" throws. export function readLinkTitleWindowTitle(window) { try { - if (!window || window.isDestroyed()) {return ''} + if (!window || window.isDestroyed()) { + return '' + } const contents = window.webContents - if (!contents || contents.isDestroyed()) {return ''} + if (!contents || contents.isDestroyed()) { + return '' + } return contents.getTitle() || '' } catch { diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 93f2893d6766..f569bf2dd458 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1,15 +1,16 @@ type Method = string -import type { ExecFileSyncOptionsWithStringEncoding} from 'node:child_process'; +import type { ExecFileSyncOptionsWithStringEncoding } from 'node:child_process' import { execFileSync, spawn } from 'node:child_process' import crypto from 'node:crypto' import fs from 'node:fs' import http from 'node:http' import https from 'node:https' -import os from "node:os" +import os from 'node:os' import path from 'node:path' import { pathToFileURL } from 'node:url' -import { app, +import { + app, BrowserWindow, clipboard, dialog, @@ -25,16 +26,18 @@ import { app, screen, session, shell, - systemPreferences } from 'electron' -import nodePty from "node-pty" + systemPreferences +} from 'electron' +import nodePty from 'node-pty' -import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'; +import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env' import { canImportHermesCli, verifyHermesCli } from './backend-probes' import { waitForDashboardPortAnnouncement } from './backend-ready' import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform' import { runBootstrap } from './bootstrap-runner' -import { authModeFromStatus, +import { + authModeFromStatus, buildGatewayWsUrl, buildGatewayWsUrlWithTicket, connectionScopeKey, @@ -46,20 +49,24 @@ import { authModeFromStatus, profileRemoteOverride, resolveAuthMode, resolveTestWsUrl, - tokenPreview } from './connection-config' + tokenPreview +} from './connection-config' import { adoptServedDashboardToken } from './dashboard-token' -import { buildPosixCleanupScript, +import { + buildPosixCleanupScript, buildWindowsCleanupScript, modeRemovesAgent, modeRemovesUserData, resolveRemovableAppPath, shouldRemoveAppBundle, - uninstallArgsForMode } from './desktop-uninstall' + uninstallArgsForMode +} from './desktop-uninstall' import { installEmbedReferer } from './embed-referer' import { readDirForIpc } from './fs-read-dir' import { probeGatewayWebSocket } from './gateway-ws-probe' import { scanGitRepos } from './git-repo-scan' -import { fileDiffVsHead, +import { + fileDiffVsHead, repoStatus, reviewCommit, reviewCommitContext, @@ -71,41 +78,50 @@ import { fileDiffVsHead, reviewRevParse, reviewShipInfo, reviewStage, - reviewUnstage } from './git-review-ops' + reviewUnstage +} from './git-review-ops' import { gitRootForIpc } from './git-root' import { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops' -import { DATA_URL_READ_MAX_BYTES, +import { + DATA_URL_READ_MAX_BYTES, DEFAULT_FETCH_TIMEOUT_MS, encryptDesktopSecret as encryptDesktopSecretStrict, resolveReadableFileForIpc, resolveRequestedPathForIpc, resolveTimeoutMs, - TEXT_PREVIEW_SOURCE_MAX_BYTES } from './hardening' + TEXT_PREVIEW_SOURCE_MAX_BYTES +} from './hardening' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' -import { buildSessionWindowUrl, +import { + buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry, SESSION_WINDOW_MIN_HEIGHT, - SESSION_WINDOW_MIN_WIDTH } from './session-windows' + SESSION_WINDOW_MIN_WIDTH +} from './session-windows' import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' import { resolveBehindCount, shouldCountCommits } from './update-count' import { readLiveUpdateMarker, writeUpdateMarker } from './update-marker' import { runRebuildWithRetry } from './update-rebuild' -import { buildRelaunchScript, +import { + buildRelaunchScript, collectRelaunchArgs, collectRelaunchEnv, decideRelaunchOutcome, resolveUnpackedRelease, sandboxFallbackFromEnv, - sandboxPreflight } from './update-relaunch' + sandboxPreflight +} from './update-relaunch' import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace' -import { computeWindowOptions, +import { + computeWindowOptions, debounce, sanitizeWindowState, MIN_HEIGHT as WINDOW_MIN_HEIGHT, - MIN_WIDTH as WINDOW_MIN_WIDTH } from './window-state' + MIN_WIDTH as WINDOW_MIN_WIDTH +} from './window-state' import { readWindowsUserEnvVar } from './windows-user-env' import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd' import { readWslWindowsClipboardImage } from './wsl-clipboard-image' @@ -134,7 +150,7 @@ const PRELOAD_PATH = IS_PACKAGED ? path.join(APP_ROOT, 'dist', 'electron-preload.js') : path.join(import.meta.dirname, 'preload.ts') -function hiddenWindowsChildOptions(options: any = {}): ExecFileSyncOptionsWithStringEncoding { +function hiddenWindowsChildOptions(options: any = {}): ExecFileSyncOptionsWithStringEncoding { if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) { return options as any } @@ -212,10 +228,9 @@ function loadInstallStamp() { // sees a stamp without needing a packaged build. const candidates = [ process.resourcesPath ? path.join(process.resourcesPath, 'install-stamp.json') : null, - path.join(APP_ROOT, 'build', 'install-stamp.json'), + path.join(APP_ROOT, 'build', 'install-stamp.json') ].filter(Boolean) - for (const p of candidates) { try { const raw = fs.readFileSync(p, 'utf8') @@ -279,9 +294,13 @@ if (INSTALL_STAMP) { // HERMES_HOME beneath the throwaway userData dir so a fresh-install run never // touches the user's real ~/.hermes / %LOCALAPPDATA%\hermes. function resolveHermesHome() { - if (process.env.HERMES_HOME) {return normalizeHermesHomeRoot(process.env.HERMES_HOME)} + if (process.env.HERMES_HOME) { + return normalizeHermesHomeRoot(process.env.HERMES_HOME) + } - if (USER_DATA_OVERRIDE) {return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home')} + if (USER_DATA_OVERRIDE) { + return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home') + } if (IS_WINDOWS) { // A GUI app launched from Explorer inherits the environment block captured @@ -292,7 +311,9 @@ function resolveHermesHome() { // Consult the live User-scoped registry value before the default below. const fromRegistry = readWindowsUserEnvVar('HERMES_HOME') - if (fromRegistry) {return normalizeHermesHomeRoot(fromRegistry)} + if (fromRegistry) { + return normalizeHermesHomeRoot(fromRegistry) + } } if (IS_WINDOWS && process.env.LOCALAPPDATA) { @@ -301,7 +322,9 @@ function resolveHermesHome() { // Migrate transparently to LOCALAPPDATA, but honour an existing legacy // ~/.hermes setup (no LOCALAPPDATA install yet) so users don't lose state. - if (!directoryExists(localappdata) && directoryExists(legacy)) {return legacy} + if (!directoryExists(localappdata) && directoryExists(legacy)) { + return legacy + } return localappdata } @@ -393,7 +416,9 @@ const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1' const BOOT_FAKE_STEP_MS = (() => { const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10) - if (!Number.isFinite(raw) || raw <= 0) {return 650} + if (!Number.isFinite(raw) || raw <= 0) { + return 650 + } return Math.max(120, raw) })() @@ -636,15 +661,21 @@ const PREVIEW_LANGUAGE_BY_EXT = { } function looksBinary(buffer) { - if (!buffer.length) {return false} + if (!buffer.length) { + return false + } let suspicious = 0 for (const byte of buffer) { - if (byte === 0) {return true} + if (byte === 0) { + return true + } // Allow common whitespace controls: tab, LF, CR. - if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {suspicious += 1} + if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) { + suspicious += 1 + } } return suspicious / buffer.length > 0.12 @@ -831,7 +862,9 @@ let bootProgressState = { // Each step is ['rm', path] or ['mv', src, dst]; executed best-effort so a // missing chain link never aborts the rest. function planDesktopLogRotation(size) { - if (size < DESKTOP_LOG_MAX_BYTES) {return []} + if (size < DESKTOP_LOG_MAX_BYTES) { + return [] + } const backups = n => Array.from({ length: n }, (_, i) => desktopLogBackupPath(i + 1)) // Pathological boot-loop log: reclaim live + every backup outright. @@ -862,8 +895,11 @@ function rotateDesktopLogIfNeededSync() { for (const [op, src, dst] of planDesktopLogRotation(size)) { try { - if (op === 'rm') {fs.rmSync(src, { force: true })} - else {fs.renameSync(src, dst)} + if (op === 'rm') { + fs.rmSync(src, { force: true }) + } else { + fs.renameSync(src, dst) + } } catch { // Best-effort — logging must never block startup/shutdown. } @@ -881,8 +917,11 @@ async function rotateDesktopLogIfNeededAsync() { for (const [op, src, dst] of planDesktopLogRotation(size)) { try { - if (op === 'rm') {await fs.promises.rm(src, { force: true })} - else {await fs.promises.rename(src, dst)} + if (op === 'rm') { + await fs.promises.rm(src, { force: true }) + } else { + await fs.promises.rename(src, dst) + } } catch { // Best-effort — logging must never crash the shell. } @@ -890,7 +929,9 @@ async function rotateDesktopLogIfNeededAsync() { } function flushDesktopLogBufferSync() { - if (!desktopLogBuffer) {return} + if (!desktopLogBuffer) { + return + } const chunk = desktopLogBuffer desktopLogBuffer = '' @@ -904,7 +945,9 @@ function flushDesktopLogBufferSync() { } function flushDesktopLogBufferAsync() { - if (!desktopLogBuffer) {return desktopLogFlushPromise} + if (!desktopLogBuffer) { + return desktopLogFlushPromise + } const chunk = desktopLogBuffer desktopLogBuffer = '' @@ -922,7 +965,9 @@ function flushDesktopLogBufferAsync() { } function scheduleDesktopLogFlush() { - if (desktopLogFlushTimer) {return} + if (desktopLogFlushTimer) { + return + } desktopLogFlushTimer = setTimeout(() => { desktopLogFlushTimer = null void flushDesktopLogBufferAsync() @@ -932,7 +977,9 @@ function scheduleDesktopLogFlush() { function rememberLog(chunk) { const text = String(chunk || '').trim() - if (!text) {return} + if (!text) { + return + } const lines = text.split(/\r?\n/).map(line => `[hermes] ${line}`) hermesLog.push(...lines) @@ -959,7 +1006,9 @@ function rememberLog(chunk) { function openExternalUrl(rawUrl) { const raw = String(rawUrl || '').trim() - if (!raw) {return false} + if (!raw) { + return false + } let parsed @@ -1035,7 +1084,9 @@ function openExternalUrl(rawUrl) { async function openPreviewInBrowser(rawUrl) { const raw = String(rawUrl || '').trim() - if (!raw) {return false} + if (!raw) { + return false + } let parsed @@ -1063,7 +1114,9 @@ async function openPreviewInBrowser(rawUrl) { } function ensureWslWindowsFonts() { - if (!IS_WSL) {return} + if (!IS_WSL) { + return + } const fontsDir = ['/mnt/c/Windows/Fonts', '/mnt/c/windows/fonts'].find(candidate => { try { @@ -1073,7 +1126,9 @@ function ensureWslWindowsFonts() { } }) - if (!fontsDir) {return} + if (!fontsDir) { + return + } try { const confDir = path.join(app.getPath('home'), '.config', 'fontconfig', 'conf.d') @@ -1086,7 +1141,9 @@ function ensureWslWindowsFonts() { existing = '' } - if (existing.includes(fontsDir)) {return} + if (existing.includes(fontsDir)) { + return + } fs.mkdirSync(confDir, { recursive: true }) fs.writeFileSync( @@ -1110,16 +1167,22 @@ function sleep(ms) { function clampBootProgress(value) { const numeric = Number(value) - if (!Number.isFinite(numeric)) {return 0} + if (!Number.isFinite(numeric)) { + return 0 + } return Math.max(0, Math.min(100, Math.round(numeric))) } function broadcastBootProgress() { - if (!mainWindow || mainWindow.isDestroyed()) {return} + if (!mainWindow || mainWindow.isDestroyed()) { + return + } const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) {return} + if (!webContents || webContents.isDestroyed()) { + return + } webContents.send('hermes:boot-progress', bootProgressState) } @@ -1196,10 +1259,14 @@ function broadcastBootstrapEvent(ev) { } } - if (!mainWindow || mainWindow.isDestroyed()) {return} + if (!mainWindow || mainWindow.isDestroyed()) { + return + } const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) {return} + if (!webContents || webContents.isDestroyed()) { + return + } webContents.send('hermes:bootstrap:event', ev) } @@ -1207,7 +1274,7 @@ function getBootstrapState() { return bootstrapState } -function updateBootProgress(update, options: {allowDecrease?: boolean} = {}) { +function updateBootProgress(update, options: { allowDecrease?: boolean } = {}) { const nextProgressRaw = typeof update.progress === 'number' ? clampBootProgress(update.progress) : bootProgressState.progress @@ -1292,7 +1359,9 @@ const UPDATE_HANDOFF_DWELL_MS = 2500 async function waitForUpdateToFinish() { let marker = readLiveUpdateMarker(HERMES_HOME) - if (!marker) {return false} + if (!marker) { + return false + } rememberLog(`[updates] update in progress (pid=${marker.pid}); deferring backend start until it finishes`) const deadline = Date.now() + UPDATE_WAIT_TIMEOUT_MS @@ -1321,12 +1390,18 @@ function unpackedPathFor(filePath) { } function findOnPath(command) { - if (!command) {return null} + if (!command) { + return null + } if (path.isAbsolute(command) || command.includes(path.sep) || (IS_WINDOWS && command.includes('/'))) { - if (!fileExists(command)) {return null} + if (!fileExists(command)) { + return null + } - if (isWindowsBinaryPathInWsl(command, { isWsl: IS_WSL })) {return null} + if (isWindowsBinaryPathInWsl(command, { isWsl: IS_WSL })) { + return null + } return command } @@ -1349,7 +1424,9 @@ function findOnPath(command) { for (const extension of extensions) { const candidate = path.join(entry, `${command}${extension}`) - if (fileExists(candidate)) {return candidate} + if (fileExists(candidate)) { + return candidate + } } } @@ -1361,20 +1438,28 @@ function isCommandScript(command) { } function unwrapWindowsVenvHermesCommand(command, backendArgs) { - if (!IS_WINDOWS || !command || isCommandScript(command)) {return null} + if (!IS_WINDOWS || !command || isCommandScript(command)) { + return null + } const resolved = path.resolve(String(command)) - if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) {return null} + if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) { + return null + } const scriptsDir = path.dirname(resolved) - if (path.basename(scriptsDir).toLowerCase() !== 'scripts') {return null} + if (path.basename(scriptsDir).toLowerCase() !== 'scripts') { + return null + } const venvRoot = path.dirname(scriptsDir) const python = getVenvPython(venvRoot) - if (!fileExists(python)) {return null} + if (!fileExists(python)) { + return null + } const root = path.dirname(venvRoot) @@ -1389,7 +1474,9 @@ function unwrapWindowsVenvHermesCommand(command, backendArgs) { if ( !canImportHermesCli(python, { env: { - PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH].filter(Boolean).join(path.delimiter) + PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH] + .filter(Boolean) + .join(path.delimiter) } }) ) { @@ -1431,10 +1518,14 @@ function unwrapWindowsVenvHermesCommand(command, backendArgs) { const _serveSupportCache = new Map() function backendSupportsServe(backend) { - if (!backend || !backend.command) {return true} + if (!backend || !backend.command) { + return true + } const key = `${backend.command}::${backend.root || ''}` - if (_serveSupportCache.has(key)) {return _serveSupportCache.get(key)} + if (_serveSupportCache.has(key)) { + return _serveSupportCache.get(key) + } let supported = null @@ -1479,7 +1570,9 @@ function getBackendArgsForRuntime(backend) { } function normalizeExecutablePathForCompare(commandPath) { - if (!commandPath) {return null} + if (!commandPath) { + return null + } let resolved = path.resolve(String(commandPath)) @@ -1493,7 +1586,9 @@ function normalizeExecutablePathForCompare(commandPath) { } function looksLikeDesktopAppBinary(commandPath) { - if (!IS_WINDOWS || !commandPath) {return false} + if (!IS_WINDOWS || !commandPath) { + return false + } const normalizedCandidate = normalizeExecutablePathForCompare(commandPath) const normalizedCurrentExec = normalizeExecutablePathForCompare(process.execPath) @@ -1524,7 +1619,9 @@ function isHermesSourceRoot(root) { function findPythonForRoot(root) { const override = process.env.HERMES_DESKTOP_PYTHON - if (override && fileExists(override)) {return override} + if (override && fileExists(override)) { + return override + } const relativePaths = IS_WINDOWS ? [path.join('.venv', 'Scripts', 'python.exe'), path.join('venv', 'Scripts', 'python.exe')] @@ -1533,7 +1630,9 @@ function findPythonForRoot(root) { for (const relativePath of relativePaths) { const candidate = path.join(root, relativePath) - if (fileExists(candidate)) {return candidate} + if (fileExists(candidate)) { + return candidate + } } return findSystemPython() @@ -1545,7 +1644,9 @@ function findSystemPython() { for (const command of ['python3', 'python']) { const candidate = findOnPath(command) - if (candidate) {return candidate} + if (candidate) { + return candidate + } } return null @@ -1611,7 +1712,9 @@ function findSystemPython() { const installPath = match[1].trim() const pythonExe = path.join(installPath, 'python.exe') - if (fileExists(pythonExe)) {return pythonExe} + if (fileExists(pythonExe)) { + return pythonExe + } } } catch { // Key not present — try next. @@ -1626,12 +1729,16 @@ function findSystemPython() { for (const versionDir of SUPPORTED_VERSIONS_NO_DOT) { const systemWide = path.join(programFiles, `Python${versionDir}`, 'python.exe') - if (fileExists(systemWide)) {return systemWide} + if (fileExists(systemWide)) { + return systemWide + } if (localAppData) { const perUser = path.join(localAppData, 'Programs', 'Python', `Python${versionDir}`, 'python.exe') - if (fileExists(perUser)) {return perUser} + if (fileExists(perUser)) { + return perUser + } } } @@ -1656,7 +1763,9 @@ function findSystemPython() { const candidate = out.trim() - if (candidate && fileExists(candidate)) {return candidate} + if (candidate && fileExists(candidate)) { + return candidate + } } catch { // py couldn't find that version — try next. } @@ -1705,7 +1814,9 @@ function findGitBash() { } for (const candidate of candidates) { - if (fileExists(candidate)) {return candidate} + if (fileExists(candidate)) { + return candidate + } } // Last resort — bash on PATH (covers WSL bash, MSYS2, custom installs). @@ -1742,12 +1853,16 @@ function getVenvPython(venvRoot) { function getVenvSitePackagesEntries(venvRoot) { const entries = [] - if (!venvRoot) {return entries} + if (!venvRoot) { + return entries + } if (IS_WINDOWS) { const sitePackages = path.join(venvRoot, 'Lib', 'site-packages') - if (directoryExists(sitePackages)) {entries.push(sitePackages)} + if (directoryExists(sitePackages)) { + entries.push(sitePackages) + } return entries } @@ -1766,7 +1881,9 @@ function getVenvSitePackagesEntries(venvRoot) { if (version) { const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages') - if (directoryExists(sitePackages)) {entries.push(sitePackages)} + if (directoryExists(sitePackages)) { + entries.push(sitePackages) + } } return entries @@ -1787,7 +1904,9 @@ function makeDashboardReadyFile() { let _gitBinaryCache = null function resolveGitBinary() { - if (_gitBinaryCache) {return _gitBinaryCache} + if (_gitBinaryCache) { + return _gitBinaryCache + } if (!IS_WINDOWS) { _gitBinaryCache = findOnPath('git') || 'git' @@ -1822,7 +1941,9 @@ function resolveGitBinary() { let _ghBinaryCache = null function resolveGhBinary() { - if (_ghBinaryCache) {return _ghBinaryCache} + if (_ghBinaryCache) { + return _ghBinaryCache + } const candidates = [] @@ -1886,7 +2007,9 @@ function readWindowState() { // getNormalBounds() keeps the pre-maximize size, so un-maximizing next session // lands back where the user actually sized the window. function persistWindowState() { - if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isMinimized()) {return} + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isMinimized()) { + return + } try { const { x, y, width, height } = mainWindow.getNormalBounds() @@ -1916,14 +2039,14 @@ function resolveUpdateRoot() { return candidates.find(c => directoryExists(path.join(c, '.git'))) || candidates[0] || ACTIVE_HERMES_ROOT } -function runGit(args, options: any = {}): Promise<{code: number, stdout: string, stderr: string}> { +function runGit(args, options: any = {}): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn( resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({ cwd: options.cwd, - env: { ...process.env, ...(options.env || {}) as any, GIT_TERMINAL_PROMPT: '0' }, + env: { ...process.env, ...((options.env || {}) as any), GIT_TERMINAL_PROMPT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] }) ) @@ -2154,7 +2277,9 @@ function resolveUpdaterBinary() { } function repairMacUpdaterHelper(updater) { - if (!IS_MAC || !updater) {return} + if (!IS_MAC || !updater) { + return + } try { execFileSync('/usr/bin/xattr', ['-cr', updater], { stdio: 'ignore' }) @@ -2193,7 +2318,9 @@ function venvHermesShimPath(updateRoot) { // this practically always succeeds (no mandatory locking), so it returns false // — correct, since the shim-contention brick is Windows-only. function isShimLocked(shimPath) { - if (!IS_WINDOWS) {return false} + if (!IS_WINDOWS) { + return false + } let fd try { @@ -2224,9 +2351,13 @@ function isShimLocked(shimPath) { // not a process-group leader — a POSIX negative-pgid kill would be meaningless // here anyway). POSIX teardown stays with the existing before-quit SIGTERM. function forceKillProcessTree(pid) { - if (!IS_WINDOWS) {return} + if (!IS_WINDOWS) { + return + } - if (!Number.isInteger(pid) || pid <= 0) {return} + if (!Number.isInteger(pid) || pid <= 0) { + return + } try { execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' })) @@ -2267,15 +2398,21 @@ async function releaseBackendLockForUpdate(updateRoot) { // `tag` only flavors the log lines. No-op off Windows (POSIX has no mandatory // locks — the before-quit SIGTERM + the cleanup script's own PID-wait suffice). async function releaseBackendLock(updateRoot, tag) { - if (!IS_WINDOWS) {return { unlocked: true }} + if (!IS_WINDOWS) { + return { unlocked: true } + } // Collect every backend PID the desktop owns: primary window backend + pool. const pids = [] - if (hermesProcess && Number.isInteger(hermesProcess.pid)) {pids.push(hermesProcess.pid)} + if (hermesProcess && Number.isInteger(hermesProcess.pid)) { + pids.push(hermesProcess.pid) + } for (const entry of backendPool.values()) { - if (entry.process && Number.isInteger(entry.process.pid)) {pids.push(entry.process.pid)} + if (entry.process && Number.isInteger(entry.process.pid)) { + pids.push(entry.process.pid) + } } // Graceful first (lets Python flush), then tree-kill to catch grandchildren. @@ -2289,7 +2426,9 @@ async function releaseBackendLock(updateRoot, tag) { stopAllPoolBackends() - for (const pid of pids) {forceKillProcessTree(pid)} + for (const pid of pids) { + forceKillProcessTree(pid) + } const shim = venvHermesShimPath(updateRoot) const deadlineMs = Date.now() + 15000 @@ -2306,13 +2445,19 @@ async function releaseBackendLock(updateRoot, tag) { // instead of trusting the initial sweep. const stragglers = [] - if (hermesProcess && Number.isInteger(hermesProcess.pid)) {stragglers.push(hermesProcess.pid)} - - for (const entry of backendPool.values()) { - if (entry.process && Number.isInteger(entry.process.pid)) {stragglers.push(entry.process.pid)} + if (hermesProcess && Number.isInteger(hermesProcess.pid)) { + stragglers.push(hermesProcess.pid) } - for (const pid of stragglers) {forceKillProcessTree(pid)} + for (const entry of backendPool.values()) { + if (entry.process && Number.isInteger(entry.process.pid)) { + stragglers.push(entry.process.pid) + } + } + + for (const pid of stragglers) { + forceKillProcessTree(pid) + } await new Promise(r => setTimeout(r, 300)) } @@ -2323,7 +2468,9 @@ async function releaseBackendLock(updateRoot, tag) { // imports broken (the July 2026 brotlicffi/_sodium.pyd incidents). Failing // the update loudly and keeping the app running is strictly better than a // bricked install that needs manual venv surgery. - rememberLog(`[${tag}] venv shim still locked after 15s; aborting hand-off (something outside this app holds the venv)`) + rememberLog( + `[${tag}] venv shim still locked after 15s; aborting hand-off (something outside this app holds the venv)` + ) return { unlocked: false } } @@ -2378,7 +2525,9 @@ async function applyUpdates(opts = {}) { if (head.code === 0 && current && current !== 'HEAD') { const branch = await resolveHealedBranch(updateRoot, current) - if (branch !== 'main') {command = `hermes update --branch ${branch}`} + if (branch !== 'main') { + command = `hermes update --branch ${branch}` + } } } catch { // Best-effort: fall back to bare `hermes update` if branch detection fails. @@ -2478,11 +2627,15 @@ async function applyUpdates(opts = {}) { } async function handOffWindowsBootstrapRecovery(reason) { - if (!IS_WINDOWS || !IS_PACKAGED) {return false} + if (!IS_WINDOWS || !IS_PACKAGED) { + return false + } const updater = resolveUpdaterBinary() - if (!updater) {return false} + if (!updater) { + return false + } const updateRoot = resolveUpdateRoot() const { branch: configuredBranch } = readDesktopUpdateConfig() @@ -2548,7 +2701,9 @@ async function handOffWindowsBootstrapRecovery(reason) { function resolveHermesCliBinary(updateRoot) { const venvHermes = path.join(updateRoot, 'venv', 'bin', 'hermes') - if (fileExists(venvHermes)) {return venvHermes} + if (fileExists(venvHermes)) { + return venvHermes + } return findOnPath('hermes') || null } @@ -2578,7 +2733,9 @@ function runStreamedUpdate(command, args, { cwd, env, stage }: any = {}) { for (const line of chunk.toString().split('\n')) { const trimmed = line.trim() - if (trimmed) {emitUpdateProgress({ stage, message: trimmed, percent: null })} + if (trimmed) { + emitUpdateProgress({ stage, message: trimmed, percent: null }) + } } } @@ -2592,10 +2749,14 @@ function runStreamedUpdate(command, args, { cwd, env, stage }: any = {}) { // The running app's .app bundle (packaged macOS): execPath is // <App>.app/Contents/MacOS/<exe>; climb three levels to the bundle root. function runningAppBundle() { - if (!IS_MAC) {return null} + if (!IS_MAC) { + return null + } let dir = path.dirname(app.getPath('exe')) // .../Contents/MacOS - for (let i = 0; i < 2; i++) {dir = path.dirname(dir)} // -> .../X.app + for (let i = 0; i < 2; i++) { + dir = path.dirname(dir) + } // -> .../X.app return dir.endsWith('.app') ? dir : null } @@ -2670,11 +2831,11 @@ async function applyUpdatesPosixInApp(opts: any) { emitUpdateProgress({ stage: 'update', message: 'Updating Hermes (git + dependencies)…', percent: 10 }) - const updated = await runStreamedUpdate(hermes, ['update', '--yes', ...branchArgs], { + const updated = (await runStreamedUpdate(hermes, ['update', '--yes', ...branchArgs], { cwd: updateRoot, env, stage: 'update' - }) as any + })) as any if (updated.code !== 0) { emitUpdateProgress({ stage: 'error', message: 'hermes update failed.', error: updated.error || 'update-failed' }) @@ -2940,11 +3101,17 @@ function isActiveRuntimeUsable() { function isBootstrapComplete() { const marker = readBootstrapMarker() - if (!marker || typeof marker !== 'object') {return false} + if (!marker || typeof marker !== 'object') { + return false + } - if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) {return false} + if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) { + return false + } - if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {return false} + if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) { + return false + } // We DELIBERATELY do NOT verify that the checkout is currently at the // pinned commit -- users update via the in-app update path or `hermes @@ -2975,11 +3142,15 @@ function writeBootstrapMarker(payload) { function resolveWebDist() { const override = process.env.HERMES_DESKTOP_WEB_DIST - if (override && directoryExists(path.resolve(override))) {return path.resolve(override)} + if (override && directoryExists(path.resolve(override))) { + return path.resolve(override) + } const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist') - if (directoryExists(unpackedDist)) {return unpackedDist} + if (directoryExists(unpackedDist)) { + return unpackedDist + } // Final fallback: APP_ROOT/dist. When packaged with asar:true this lives // INSIDE app.asar — not a servable filesystem directory — so the embedded @@ -3004,7 +3175,9 @@ function resolveRendererIndex() { const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')] const found = candidates.find(fileExists) - if (found) {return found} + if (found) { + return found + } // Nothing on disk. A packaged build with no renderer bundle blank-pages with // a bare ERR_FILE_NOT_FOUND and no clue why (see #39484). Surface the cause // and the fix before Electron loads the missing file. @@ -3050,14 +3223,18 @@ function resolveHermesCwd() { ] for (const candidate of candidates) { - if (!candidate) {continue} + if (!candidate) { + continue + } const resolved = path.resolve(String(candidate)) if (isPackagedInstallPath(resolved)) { continue } - if (directoryExists(resolved)) {return resolved} + if (directoryExists(resolved)) { + return resolved + } } return app.getPath('home') @@ -3128,7 +3305,9 @@ function writeDefaultProjectDir(dir) { function createPythonBackend(root, label, backendArgs, options: any = {}) { const python = findPythonForRoot(root) - if (!python) {return null} + if (!python) { + return null + } const venvRoot = path.join(root, 'venv') const venvPython = getVenvPython(venvRoot) @@ -3182,7 +3361,9 @@ function resolveHermesBackend(backendArgs) { if (overrideRoot && isHermesSourceRoot(overrideRoot)) { const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, backendArgs) - if (backend) {return backend} + if (backend) { + return backend + } } // 2. Development source -- when running `npm run dev` from a checkout, the @@ -3192,7 +3373,9 @@ function resolveHermesBackend(backendArgs) { if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) { const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, backendArgs) - if (backend) {return backend} + if (backend) { + return backend + } } // 3. Bootstrap-complete ACTIVE_HERMES_ROOT -- the canonical install at @@ -3346,7 +3529,7 @@ async function ensureRuntime(backend) { rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap') if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) { - const handoffError: Error & {isBootstrapFailure?: boolean, bootstrapHandedOff?: boolean} = new Error( + const handoffError: Error & { isBootstrapFailure?: boolean; bootstrapHandedOff?: boolean } = new Error( 'Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.' ) @@ -3564,7 +3747,9 @@ function fetchJson(url, token, options: any = {}) { req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) - if (body) {req.write(body)} + if (body) { + req.write(body) + } req.end() }) } @@ -3651,7 +3836,9 @@ function fetchPublicJson(url, options: any = {}) { req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) - if (body) {req.write(body)} + if (body) { + req.write(body) + } req.end() }) } @@ -3668,17 +3855,29 @@ function extensionForMimeType(mimeType) { .trim() .toLowerCase() - if (type === 'image/png') {return '.png'} + if (type === 'image/png') { + return '.png' + } - if (type === 'image/jpeg') {return '.jpg'} + if (type === 'image/jpeg') { + return '.jpg' + } - if (type === 'image/gif') {return '.gif'} + if (type === 'image/gif') { + return '.gif' + } - if (type === 'image/webp') {return '.webp'} + if (type === 'image/webp') { + return '.webp' + } - if (type === 'image/bmp') {return '.bmp'} + if (type === 'image/bmp') { + return '.bmp' + } - if (type === 'image/svg+xml') {return '.svg'} + if (type === 'image/svg+xml') { + return '.svg' + } return '' } @@ -3738,7 +3937,9 @@ const renderTitleQueue = [] function canonicalTitleCacheKey(rawUrl) { const value = String(rawUrl || '').trim() - if (!value) {return ''} + if (!value) { + return '' + } try { const url = new URL(value) @@ -3752,7 +3953,9 @@ function canonicalTitleCacheKey(rawUrl) { } function cacheTitle(key, title) { - if (titleCache.size >= TITLE_CACHE_LIMIT) {titleCache.delete(titleCache.keys().next().value)} + if (titleCache.size >= TITLE_CACHE_LIMIT) { + titleCache.delete(titleCache.keys().next().value) + } titleCache.set(key, title) } @@ -3773,7 +3976,9 @@ function fetchHtmlTitleWithCurl(rawUrl: string): Promise<string> { return new Promise(resolve => { const url = String(rawUrl || '').trim() - if (!url) {return resolve('')} + if (!url) { + return resolve('') + } const args = [ '--silent', @@ -3802,7 +4007,9 @@ function fetchHtmlTitleWithCurl(rawUrl: string): Promise<string> { let bytes = 0 child.stdout.on('data', chunk => { - if (bytes >= TITLE_BYTE_BUDGET) {return} + if (bytes >= TITLE_BYTE_BUDGET) { + return + } const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) const remaining = TITLE_BYTE_BUDGET - bytes const next = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer @@ -3812,14 +4019,18 @@ function fetchHtmlTitleWithCurl(rawUrl: string): Promise<string> { child.on('error', () => resolve('')) child.on('close', () => { - if (!chunks.length) {return resolve('')} + if (!chunks.length) { + return resolve('') + } resolve(parseHtmlTitle(Buffer.concat(chunks).toString('utf8'))) }) }) } function getLinkTitleSession() { - if (linkTitleSession || !app.isReady()) {return linkTitleSession} + if (linkTitleSession || !app.isReady()) { + return linkTitleSession + } linkTitleSession = session.fromPartition('hermes:link-titles', { cache: false }) linkTitleSession.webRequest.onBeforeRequest((details, callback) => { callback({ cancel: RENDER_TITLE_BLOCKED_RESOURCES.has(details.resourceType) }) @@ -3843,11 +4054,15 @@ function dequeueRenderTitle() { function runRenderTitleJob(rawUrl) { return new Promise(resolve => { - if (!app.isReady()) {return resolve('')} + if (!app.isReady()) { + return resolve('') + } const partitionSession = getLinkTitleSession() - if (!partitionSession) {return resolve('')} + if (!partitionSession) { + return resolve('') + } let settled = false let window = null @@ -3855,16 +4070,24 @@ function runRenderTitleJob(rawUrl) { let graceTimer = null const finish = title => { - if (settled) {return} + if (settled) { + return + } settled = true - if (hardTimer) {clearTimeout(hardTimer)} + if (hardTimer) { + clearTimeout(hardTimer) + } - if (graceTimer) {clearTimeout(graceTimer)} + if (graceTimer) { + clearTimeout(graceTimer) + } const value = (title || '').replace(/\s+/g, ' ').trim() try { - if (window && !window.isDestroyed()) {window.destroy()} + if (window && !window.isDestroyed()) { + window.destroy() + } } catch { // BrowserWindow may already be torn down; ignore. } @@ -3881,7 +4104,9 @@ function runRenderTitleJob(rawUrl) { const finishWithTitle = () => finish(readLinkTitleWindowTitle(window)) const scheduleGrace = () => { - if (graceTimer) {clearTimeout(graceTimer)} + if (graceTimer) { + clearTimeout(graceTimer) + } graceTimer = setTimeout(finishWithTitle, RENDER_TITLE_GRACE_MS) } @@ -3891,7 +4116,9 @@ function runRenderTitleJob(rawUrl) { window.webContents.on('page-title-updated', scheduleGrace) window.webContents.on('did-finish-load', scheduleGrace) window.webContents.on('did-fail-load', (_event, _code, _desc, _validatedURL, isMainFrame) => { - if (isMainFrame) {finish('')} + if (isMainFrame) { + finish('') + } }) window @@ -3912,19 +4139,25 @@ function fetchHtmlTitleWithRenderer(rawUrl: string): Promise<string> { // Strips known error/captcha titles (e.g. "GetYourGuide – Error", "Just a // moment...") so they don't get cached as the resolved title. -function usableTitle (value: string): string { - return (value && !TITLE_ERROR_RE.test(value) ? value : '') +function usableTitle(value: string): string { + return value && !TITLE_ERROR_RE.test(value) ? value : '' } function fetchLinkTitle(rawUrl) { const url = String(rawUrl || '').trim() const key = canonicalTitleCacheKey(url) - if (!key) {return Promise.resolve('')} + if (!key) { + return Promise.resolve('') + } - if (titleCache.has(key)) {return Promise.resolve(titleCache.get(key))} + if (titleCache.has(key)) { + return Promise.resolve(titleCache.get(key)) + } - if (titleInflight.has(key)) {return titleInflight.get(key)} + if (titleInflight.has(key)) { + return titleInflight.get(key) + } const pending = fetchHtmlTitleWithCurl(url) .catch(() => '') @@ -3945,12 +4178,16 @@ function fetchLinkTitle(rawUrl) { } async function resourceBufferFromUrl(rawUrl) { - if (!rawUrl) {throw new Error('Missing URL')} + if (!rawUrl) { + throw new Error('Missing URL') + } if (rawUrl.startsWith('data:')) { const match = rawUrl.match(/^data:([^;,]+)?(;base64)?,(.*)$/s) - if (!match) {throw new Error('Invalid data URL')} + if (!match) { + throw new Error('Invalid data URL') + } const mimeType = match[1] || 'application/octet-stream' const encoded = match[3] || '' const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8') @@ -3993,15 +4230,17 @@ async function resourceBufferFromUrl(rawUrl) { } async function copyImageFromUrl(rawUrl) { - const { buffer } = await resourceBufferFromUrl(rawUrl) as any + const { buffer } = (await resourceBufferFromUrl(rawUrl)) as any const image = nativeImage.createFromBuffer(buffer) - if (image.isEmpty()) {throw new Error('Could not read image')} + if (image.isEmpty()) { + throw new Error('Could not read image') + } clipboard.writeImage(image) } async function saveImageFromUrl(rawUrl) { - const { buffer, mimeType } = await resourceBufferFromUrl(rawUrl) as any + const { buffer, mimeType } = (await resourceBufferFromUrl(rawUrl)) as any const fallbackName = filenameFromUrl(rawUrl, `image${extensionForMimeType(mimeType) || '.png'}`) const result = await dialog.showSaveDialog(mainWindow, { @@ -4009,7 +4248,9 @@ async function saveImageFromUrl(rawUrl) { defaultPath: fallbackName }) - if (result.canceled || !result.filePath) {return false} + if (result.canceled || !result.filePath) { + return false + } await fs.promises.writeFile(result.filePath, buffer) return true @@ -4141,10 +4382,14 @@ async function filePathFromPreviewUrl(rawUrl) { } function sendPreviewFileChanged(payload) { - if (!mainWindow || mainWindow.isDestroyed()) {return} + if (!mainWindow || mainWindow.isDestroyed()) { + return + } const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) {return} + if (!webContents || webContents.isDestroyed()) { + return + } webContents.send('hermes:preview-file-changed', payload) } @@ -4162,18 +4407,24 @@ async function watchPreviewFile(rawUrl) { return } - if (timer) {clearTimeout(timer)} + if (timer) { + clearTimeout(timer) + } timer = setTimeout(() => { timer = null - if (!fileExists(filePath)) {return} + if (!fileExists(filePath)) { + return + } sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() }) }, PREVIEW_WATCH_DEBOUNCE_MS) }) previewWatchers.set(id, { close: () => { - if (timer) {clearTimeout(timer)} + if (timer) { + clearTimeout(timer) + } watcher.close() } }) @@ -4219,7 +4470,9 @@ async function waitForHermes(baseUrl, token) { } function getWindowButtonPosition() { - if (!IS_MAC) {return null} + if (!IS_MAC) { + return null + } return mainWindow?.getWindowButtonPosition?.() || WINDOW_BUTTON_POSITION } @@ -4237,18 +4490,26 @@ function getWindowState() { } function sendBackendExit(payload) { - if (!mainWindow || mainWindow.isDestroyed()) {return} + if (!mainWindow || mainWindow.isDestroyed()) { + return + } const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) {return} + if (!webContents || webContents.isDestroyed()) { + return + } webContents.send('hermes:backend-exit', payload) } function sendClosePreviewRequested() { - if (!mainWindow || mainWindow.isDestroyed()) {return} + if (!mainWindow || mainWindow.isDestroyed()) { + return + } const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) {return} + if (!webContents || webContents.isDestroyed()) { + return + } webContents.send('hermes:close-preview-requested') } @@ -4256,17 +4517,23 @@ function sendClosePreviewRequested() { // renderer's WebSocket to the local backend; the renderer reconnects on this // signal so the chat composer doesn't stay stuck on "Starting Hermes...". function sendPowerResume() { - if (!mainWindow || mainWindow.isDestroyed()) {return} + if (!mainWindow || mainWindow.isDestroyed()) { + return + } const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) {return} + if (!webContents || webContents.isDestroyed()) { + return + } webContents.send('hermes:power-resume') } let powerResumeRegistered = false function registerPowerResumeListeners() { - if (powerResumeRegistered) {return} + if (powerResumeRegistered) { + return + } powerResumeRegistered = true try { @@ -4285,21 +4552,31 @@ function getAppIconPath() { } function sendOpenUpdatesRequested() { - if (!mainWindow || mainWindow.isDestroyed()) {return} + if (!mainWindow || mainWindow.isDestroyed()) { + return + } const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) {return} + if (!webContents || webContents.isDestroyed()) { + return + } webContents.send('hermes:open-updates') - if (!mainWindow.isVisible()) {mainWindow.show()} + if (!mainWindow.isVisible()) { + mainWindow.show() + } mainWindow.focus() } function sendWindowStateChanged(nextIsFullscreen?: boolean) { - if (!mainWindow || mainWindow.isDestroyed()) {return} + if (!mainWindow || mainWindow.isDestroyed()) { + return + } const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) {return} + if (!webContents || webContents.isDestroyed()) { + return + } const state = getWindowState() if (typeof nextIsFullscreen === 'boolean') { @@ -4441,7 +4718,9 @@ function installDevToolsShortcut(window) { (IS_MAC && input.meta && input.alt && key === 'i') || (!IS_MAC && input.control && input.shift && key === 'i') - if (!isInspectShortcut) {return} + if (!isInspectShortcut) { + return + } event.preventDefault() toggleDevTools(window) }) @@ -4452,7 +4731,9 @@ function installPreviewShortcut(window) { const key = String(input.key || '').toLowerCase() const isPreviewCloseShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift - if (!isPreviewCloseShortcut || !previewShortcutActive) {return} + if (!isPreviewCloseShortcut || !previewShortcutActive) { + return + } event.preventDefault() sendClosePreviewRequested() @@ -4465,9 +4746,10 @@ function installPreviewShortcut(window) { // read it back on did-finish-load to re-apply after reloads or crash recovery. import { clampZoomLevel, percentToZoomLevel, ZOOM_STORAGE_KEY, zoomLevelToPercent } from './zoom' - function setAndPersistZoomLevel(window, zoomLevel) { - if (!window || window.isDestroyed()) {return} + if (!window || window.isDestroyed()) { + return + } const next = clampZoomLevel(zoomLevel) window.webContents.setZoomLevel(next) // Keep any open settings UI in sync, including changes made via the @@ -4481,13 +4763,17 @@ function setAndPersistZoomLevel(window, zoomLevel) { } function restorePersistedZoomLevel(window) { - if (!window || window.isDestroyed()) {return} + if (!window || window.isDestroyed()) { + return + } window.webContents .executeJavaScript( `(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()` ) .then(stored => { - if (stored == null || !window || window.isDestroyed()) {return} + if (stored == null || !window || window.isDestroyed()) { + return + } const level = clampZoomLevel(Number(stored)) window.webContents.setZoomLevel(level) }) @@ -4503,7 +4789,9 @@ function installZoomShortcuts(window) { window.webContents.on('before-input-event', (event, input) => { const mod = IS_MAC ? input.meta : input.control - if (!mod || input.alt || input.shift) {return} + if (!mod || input.alt || input.shift) { + return + } const key = input.key @@ -4559,7 +4847,9 @@ function installContextMenu(window) { } if (hasLink) { - if (template.length) {template.push({ type: 'separator' })} + if (template.length) { + template.push({ type: 'separator' }) + } template.push( { label: 'Open Link', @@ -4578,7 +4868,9 @@ function installContextMenu(window) { const suggestions = Array.isArray(params.dictionarySuggestions) ? params.dictionarySuggestions : [] if (isEditable && params.misspelledWord && suggestions.length > 0) { - if (template.length) {template.push({ type: 'separator' })} + if (template.length) { + template.push({ type: 'separator' }) + } for (const suggestion of suggestions.slice(0, 5)) { template.push({ @@ -4595,7 +4887,9 @@ function installContextMenu(window) { } if (hasSelection || isEditable) { - if (template.length) {template.push({ type: 'separator' })} + if (template.length) { + template.push({ type: 'separator' }) + } if (isEditable) { template.push( @@ -4659,7 +4953,7 @@ function installMediaPermissions() { // the check defaults to false and the mic is denied before the request // handler ever runs. session.defaultSession.setPermissionCheckHandler((_webContents, permission, _origin, details) => { - if (permission === 'media' || permission === 'audioCapture' as any /* todo: is this needed? */) { + if (permission === 'media' || permission === ('audioCapture' as any) /* todo: is this needed? */) { // details.mediaType is a single string here (not the mediaTypes array). const mediaType = details?.mediaType @@ -4706,7 +5000,9 @@ function installMediaPermissions() { const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth' function getOauthSession() { - if (oauthSession || !app.isReady()) {return oauthSession} + if (oauthSession || !app.isReady()) { + return oauthSession + } oauthSession = session.fromPartition(OAUTH_SESSION_PARTITION) return oauthSession @@ -4719,7 +5015,9 @@ function getOauthSession() { async function hasOauthSessionCookie(baseUrl) { const sess = getOauthSession() - if (!sess) {return false} + if (!sess) { + return false + } const parsed = new URL(baseUrl) try { @@ -4749,7 +5047,9 @@ async function hasOauthSessionCookie(baseUrl) { async function hasLiveOauthSession(baseUrl) { const sess = getOauthSession() - if (!sess) {return false} + if (!sess) { + return false + } const parsed = new URL(baseUrl) try { @@ -4770,7 +5070,9 @@ async function hasLiveOauthSession(baseUrl) { async function clearOauthSession(baseUrl) { const sess = getOauthSession() - if (!sess) {return} + if (!sess) { + return + } try { const cookies = await sess.cookies.get(baseUrl ? { url: baseUrl } : {}) @@ -4813,25 +5115,38 @@ function openOauthLoginWindow(baseUrl) { let pollTimer = null const finish = err => { - if (settled) {return} + if (settled) { + return + } settled = true - if (pollTimer) {clearInterval(pollTimer)} + if (pollTimer) { + clearInterval(pollTimer) + } try { - if (win && !win.isDestroyed()) {win.destroy()} + if (win && !win.isDestroyed()) { + win.destroy() + } } catch { // window already torn down } - if (err) {reject(err)} - else {resolve({ baseUrl, ok: true })} + if (err) { + reject(err) + } else { + resolve({ baseUrl, ok: true }) + } } const checkCookie = async () => { - if (settled) {return} + if (settled) { + return + } - if (await hasOauthSessionCookie(baseUrl)) {finish(null)} + if (await hasOauthSessionCookie(baseUrl)) { + finish(null) + } } try { @@ -4863,7 +5178,9 @@ function openOauthLoginWindow(baseUrl) { pollTimer = setInterval(() => void checkCookie(), 750) win.on('closed', () => { - if (!settled) {finish(new Error('Login window closed before authentication completed.'))} + if (!settled) { + finish(new Error('Login window closed before authentication completed.')) + } }) // ``next`` is intentionally omitted: the gateway lands on ``/`` after @@ -4936,7 +5253,9 @@ function fetchJsonViaOauthSession(url, options: any = {}) { const chunks = [] res.on('data', chunk => chunks.push(Buffer.from(chunk))) res.on('end', () => { - if (timedOut) {return} + if (timedOut) { + return + } clearTimeout(timer) const text = Buffer.concat(chunks).toString('utf8') const statusCode = res.statusCode || 500 @@ -4972,12 +5291,16 @@ function fetchJsonViaOauthSession(url, options: any = {}) { }) }) request.on('error', error => { - if (timedOut) {return} + if (timedOut) { + return + } clearTimeout(timer) reject(error) }) - if (body) {request.write(body)} + if (body) { + request.write(body) + } request.end() }) } @@ -4986,10 +5309,10 @@ function fetchJsonViaOauthSession(url, options: any = {}) { // Throws (with statusCode 401) if the session cookie is missing/expired — // callers treat that as "needs re-login". async function mintGatewayWsTicket(baseUrl) { - const body = await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, { + const body = (await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, { method: 'POST', timeoutMs: 8_000 - }) as any + })) as any const ticket = body?.ticket @@ -5070,7 +5393,9 @@ function sanitizeConnectionProfiles(raw: Record<string, any>) { continue } - const cleaned: {mode: 'remote' | 'local', url?: string, authMode?: string, token?: object } ={ mode: entry.mode === 'remote' ? 'remote' : 'local', } + const cleaned: { mode: 'remote' | 'local'; url?: string; authMode?: string; token?: object } = { + mode: entry.mode === 'remote' ? 'remote' : 'local' + } const url = String(entry.url || '').trim() if (url) { @@ -5231,7 +5556,7 @@ function buildRemoteBlock(remoteUrl, authMode, token) { return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token } } -function coerceDesktopConnectionConfig(input :any= {}, existing = readDesktopConnectionConfig(), options: any = {}) { +function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopConnectionConfig(), options: any = {}) { const persistToken = options.persistToken !== false const key = connectionScopeKey(input.profile) const mode = input.mode === 'remote' ? 'remote' : 'local' @@ -5467,7 +5792,7 @@ async function probeRemoteAuthMode(rawUrl) { // an OAuth-redirect one (``supports_password``). A failure here doesn't // change the auth mode, so swallow it. try { - const body = await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 }) as any + const body = (await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 })) as any if (Array.isArray(body?.providers)) { providers = body.providers @@ -5526,7 +5851,7 @@ async function testDesktopConnectionConfig(input: any = {}) { authMode = normAuthMode(remote.authMode) } - const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 }) as any + const status = (await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 })) as any // The HTTP status check above proves the backend is reachable, but the chat // surface only works once the renderer's live WebSocket to ``/api/ws`` @@ -5572,7 +5897,9 @@ function resetBootProgressForReconnect() { } function stopBackendChild(child) { - if (!child || child.killed) {return} + if (!child || child.killed) { + return + } try { if (IS_WINDOWS && Number.isInteger(child.pid)) { @@ -5683,10 +6010,14 @@ async function ensureBackend(profile) { function touchPoolBackend(profile) { const key = profile && String(profile).trim() ? String(profile).trim() : null - if (!key) {return} + if (!key) { + return + } const entry = backendPool.get(key) - if (entry) {entry.lastActiveAt = Date.now()} + if (entry) { + entry.lastActiveAt = Date.now() + } } // Evict least-recently-used pool backends until at most `keep` remain — but only @@ -5694,7 +6025,9 @@ function touchPoolBackend(profile) { // window). When every backend is actively kept alive we let the pool exceed the // soft cap rather than kill a running session. function evictLruPoolBackends(keep) { - if (backendPool.size <= keep) {return} + if (backendPool.size <= keep) { + return + } const now = Date.now() const evictable = [...backendPool.entries()] @@ -5704,7 +6037,9 @@ function evictLruPoolBackends(keep) { let removable = backendPool.size - Math.max(0, keep) for (const [profile] of evictable) { - if (removable <= 0) {break} + if (removable <= 0) { + break + } rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${POOL_MAX_BACKENDS})`) stopPoolBackend(profile) removable -= 1 @@ -5712,7 +6047,9 @@ function evictLruPoolBackends(keep) { } function startPoolIdleReaper() { - if (poolIdleReaper) {return} + if (poolIdleReaper) { + return + } poolIdleReaper = setInterval(() => { const now = Date.now() @@ -5729,7 +6066,9 @@ function startPoolIdleReaper() { } }, 60_000) - if (typeof poolIdleReaper.unref === 'function') {poolIdleReaper.unref()} + if (typeof poolIdleReaper.unref === 'function') { + poolIdleReaper.unref() + } } // Spawn an additional dashboard backend pinned to a named profile. Mirrors the @@ -5860,7 +6199,9 @@ async function spawnPoolBackend(profile, entry) { function stopPoolBackend(profile) { const entry = backendPool.get(profile) - if (!entry) {return} + if (!entry) { + return + } backendPool.delete(profile) stopBackendChild(entry.process) } @@ -5868,7 +6209,9 @@ function stopPoolBackend(profile) { async function teardownPoolBackendAndWait(profile) { const entry = backendPool.get(profile) - if (!entry) {return} + if (!entry) { + return + } backendPool.delete(profile) stopBackendChild(entry.process) @@ -5952,7 +6295,9 @@ async function startHermes() { throw backendStartFailure } - if (connectionPromise) {return connectionPromise} + if (connectionPromise) { + return connectionPromise + } connectionPromise = (async () => { await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8) @@ -6191,15 +6536,25 @@ function wireCommonWindowHandlers(win) { const sessionWindows = createSessionWindowRegistry() function focusWindow(win) { - if (!win || win.isDestroyed()) {return} + if (!win || win.isDestroyed()) { + return + } - if (win.isMinimized()) {win.restore()} + if (win.isMinimized()) { + win.restore() + } - if (!win.isVisible()) {win.show()} + if (!win.isVisible()) { + win.show() + } win.focus() } -function spawnSecondaryWindow({ sessionId, watch, newSession }:{ sessionId?: string, watch?: boolean, newSession?: boolean } = {}) { +function spawnSecondaryWindow({ + sessionId, + watch, + newSession +}: { sessionId?: string; watch?: boolean; newSession?: boolean } = {}) { const icon = getAppIconPath() const win = new BrowserWindow({ @@ -6230,7 +6585,9 @@ function spawnSecondaryWindow({ sessionId, watch, newSession }:{ sessionId?: str } win.once('ready-to-show', () => { - if (!win.isDestroyed()) {win.show()} + if (!win.isDestroyed()) { + win.show() + } }) win.on('enter-full-screen', () => sendWindowStateChanged(true)) @@ -6349,7 +6706,9 @@ function spawnPetOverlayWindow(bounds) { wireCommonWindowHandlers(win) win.once('ready-to-show', () => { - if (!win.isDestroyed()) {win.showInactive()} + if (!win.isDestroyed()) { + win.showInactive() + } }) win.on('closed', () => { @@ -6448,10 +6807,14 @@ function createWindow() { } } - if (savedWindowState?.isMaximized) {mainWindow.maximize()} + if (savedWindowState?.isMaximized) { + mainWindow.maximize() + } mainWindow.once('ready-to-show', () => { - if (mainWindow && !mainWindow.isDestroyed()) {mainWindow.show()} + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.show() + } }) mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true)) @@ -6491,7 +6854,9 @@ function createWindow() { rendererReloadTimes.push(now) setImmediate(() => { - if (!mainWindow || mainWindow.isDestroyed()) {return} + if (!mainWindow || mainWindow.isDestroyed()) { + return + } try { mainWindow.webContents.reload() @@ -6512,7 +6877,9 @@ function createWindow() { const details = detailsOrLevel && typeof detailsOrLevel === 'object' ? detailsOrLevel : null const level = details ? details.level : detailsOrLevel - if (level !== 3) {return} + if (level !== 3) { + return + } const text = details ? details.message : message const src = details ? details.sourceUrl : sourceId @@ -6611,7 +6978,9 @@ ipcMain.handle('hermes:zoom:get', event => { ipcMain.on('hermes:zoom:set-percent', (event, percent) => { const window = BrowserWindow.fromWebContents(event.sender) - if (!window || window.isDestroyed()) {return} + if (!window || window.isDestroyed()) { + return + } setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent))) }) @@ -6936,7 +7305,9 @@ async function interceptSessionRequestForRemote(request) { const body = request.body && typeof request.body === 'object' ? { ...request.body } : request.body - if (body) {delete body.profile} + if (body) { + delete body.profile + } return requestJsonForProfile(profile, pathname, method, body) } @@ -6989,10 +7360,10 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { const primary = await ensureBackend(null) - const base = await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, { + const base = (await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, { method: 'GET', timeoutMs: DEFAULT_FETCH_TIMEOUT_MS - }).catch(() => ({ sessions: [], total: 0, profile_totals: {} })) as any + }).catch(() => ({ sessions: [], total: 0, profile_totals: {} }))) as any // Over-fetch each remote from offset 0 (limit+offset rows) so the merged window // is correct for this page — mirrors the primary's per-profile over-fetch. @@ -7078,7 +7449,9 @@ ipcMain.handle('hermes:api', async (_event, request) => { }) ipcMain.handle('hermes:notify', (_event, payload) => { - if (!Notification.isSupported()) {return false} + if (!Notification.isSupported()) { + return false + } // Action buttons render only on signed macOS builds; elsewhere they're dropped // and the body click still works. const actions = Array.isArray(payload?.actions) ? payload.actions : [] @@ -7091,7 +7464,9 @@ ipcMain.handle('hermes:notify', (_event, payload) => { }) notification.on('click', () => { - if (!mainWindow || mainWindow.isDestroyed()) {return} + if (!mainWindow || mainWindow.isDestroyed()) { + return + } focusWindow(mainWindow) if (payload?.sessionId) { @@ -7099,7 +7474,9 @@ ipcMain.handle('hermes:notify', (_event, payload) => { } }) notification.on('action', (_actionEvent, index) => { - if (!mainWindow || mainWindow.isDestroyed()) {return} + if (!mainWindow || mainWindow.isDestroyed()) { + return + } const action = actions[index] if (action?.id) { @@ -7153,7 +7530,9 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => { ipcMain.handle('hermes:selectPaths', async (_event, options: any = {}) => { const properties = options?.directories ? ['openDirectory'] : ['openFile'] - if (options?.multiple !== false) {properties.push('multiSelections')} + if (options?.multiple !== false) { + properties.push('multiSelections') + } let resolvedDefaultPath @@ -7172,7 +7551,9 @@ ipcMain.handle('hermes:selectPaths', async (_event, options: any = {}) => { filters: Array.isArray(options?.filters) ? options.filters : undefined }) - if (result.canceled) {return []} + if (result.canceled) { + return [] + } return result.filePaths }) @@ -7188,7 +7569,9 @@ ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(Stri ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => { const data = payload?.data - if (!data) {throw new Error('saveImageBuffer: missing data')} + if (!data) { + throw new Error('saveImageBuffer: missing data') + } const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data) @@ -7830,7 +8213,9 @@ async function getUninstallSummary() { let settled = false const done = value => { - if (settled) {return} + if (settled) { + return + } settled = true resolve(value) } @@ -7851,7 +8236,9 @@ async function getUninstallSummary() { }) child.on('error', () => done(fallback())) child.on('exit', code => { - if (code !== 0) {return done(fallback())} + if (code !== 0) { + return done(fallback()) + } try { const line = stdout.trim().split('\n').filter(Boolean).pop() || '{}' @@ -8012,13 +8399,17 @@ let _pendingDeepLink = null let _rendererReadyForDeepLink = false function _extractDeepLink(argv) { - if (!Array.isArray(argv)) {return null} + if (!Array.isArray(argv)) { + return null + } return argv.find(a => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null } function handleDeepLink(url) { - if (!url || typeof url !== 'string') {return} + if (!url || typeof url !== 'string') { + return + } let parsed try { @@ -8045,7 +8436,9 @@ function handleDeepLink(url) { } try { - if (mainWindow.isMinimized()) {mainWindow.restore()} + if (mainWindow.isMinimized()) { + mainWindow.restore() + } mainWindow.focus() mainWindow.webContents.send('hermes:deep-link', payload) rememberLog(`[deeplink] delivered ${kind}/${name}`) @@ -8096,9 +8489,12 @@ if (!_gotSingleInstanceLock) { app.on('second-instance', (_event, argv) => { const url = _extractDeepLink(argv) - if (url) {handleDeepLink(url)} - else if (mainWindow) { - if (mainWindow.isMinimized()) {mainWindow.restore()} + if (url) { + handleDeepLink(url) + } else if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } mainWindow.focus() } }) @@ -8130,7 +8526,9 @@ app.whenReady().then(() => { // Win/Linux cold start: the launching hermes:// URL is in our own argv. const _coldStartLink = _extractDeepLink(process.argv) - if (_coldStartLink) {handleDeepLink(_coldStartLink)} + if (_coldStartLink) { + handleDeepLink(_coldStartLink) + } app.on('activate', () => { // Recreate the primary window if it's gone. Guard on mainWindow directly @@ -8206,5 +8604,7 @@ app.on('window-all-closed', () => { // the bundle and relaunch — without this the script's PID-wait spins to its // full timeout and the user is left with an invisible app (or an uninstall // that appears to do nothing). - if (process.platform !== 'darwin' || isQuittingForHandoff) {app.quit()} + if (process.platform !== 'darwin' || isQuittingForHandoff) { + app.quit() + } }) diff --git a/apps/desktop/electron/oauth-net-request.ts b/apps/desktop/electron/oauth-net-request.ts index 7c2b44821c66..bab5ef53f69d 100644 --- a/apps/desktop/electron/oauth-net-request.ts +++ b/apps/desktop/electron/oauth-net-request.ts @@ -14,5 +14,4 @@ function setJsonRequestHeaders(request) { request.setHeader('Content-Type', 'application/json') } -export { serializeJsonBody, - setJsonRequestHeaders } +export { serializeJsonBody, setJsonRequestHeaders } diff --git a/apps/desktop/electron/session-windows.test.ts b/apps/desktop/electron/session-windows.test.ts index 48f0441d23bc..cf65d39ac5d4 100644 --- a/apps/desktop/electron/session-windows.test.ts +++ b/apps/desktop/electron/session-windows.test.ts @@ -1,9 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { buildSessionWindowUrl, - chatWindowWebPreferences, - createSessionWindowRegistry } from './session-windows' +import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows' // A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a // test fire the 'closed' event, mirroring the slice of the Electron API the diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts index 7dbba58ca064..af55608b0f4e 100644 --- a/apps/desktop/electron/session-windows.ts +++ b/apps/desktop/electron/session-windows.ts @@ -115,8 +115,10 @@ function createSessionWindowRegistry() { } } -export { buildSessionWindowUrl, +export { + buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry, SESSION_WINDOW_MIN_HEIGHT, - SESSION_WINDOW_MIN_WIDTH } + SESSION_WINDOW_MIN_WIDTH +} diff --git a/apps/desktop/electron/titlebar-overlay-width.test.ts b/apps/desktop/electron/titlebar-overlay-width.test.ts index c2c77d918c2d..543a14cd56f7 100644 --- a/apps/desktop/electron/titlebar-overlay-width.test.ts +++ b/apps/desktop/electron/titlebar-overlay-width.test.ts @@ -1,7 +1,12 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { MACOS_TAHOE_DARWIN_MAJOR, macTitleBarOverlayHeight, nativeOverlayWidth, OVERLAY_FALLBACK_WIDTH } from './titlebar-overlay-width' +import { + MACOS_TAHOE_DARWIN_MAJOR, + macTitleBarOverlayHeight, + nativeOverlayWidth, + OVERLAY_FALLBACK_WIDTH +} from './titlebar-overlay-width' // This static reservation is only the pre-layout FALLBACK. Once laid out the // renderer reads the exact width from navigator.windowControlsOverlay diff --git a/apps/desktop/electron/titlebar-overlay-width.ts b/apps/desktop/electron/titlebar-overlay-width.ts index d31d40fec5a7..d6a4c5d1f241 100644 --- a/apps/desktop/electron/titlebar-overlay-width.ts +++ b/apps/desktop/electron/titlebar-overlay-width.ts @@ -15,7 +15,9 @@ export const OVERLAY_FALLBACK_WIDTH = 144 * @param {{ isMac?: boolean }} opts */ export function nativeOverlayWidth({ isWindows = false, isWsl = false, isMac = false } = {}) { - if (isMac) {return 0} + if (isMac) { + return 0 + } return OVERLAY_FALLBACK_WIDTH } @@ -38,4 +40,3 @@ export const MACOS_TAHOE_DARWIN_MAJOR = 25 export function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) { return darwinMajor >= MACOS_TAHOE_DARWIN_MAJOR ? 0 : titlebarHeight } - diff --git a/apps/desktop/electron/update-count.ts b/apps/desktop/electron/update-count.ts index b0bda95a9f0c..23fb6cac1342 100644 --- a/apps/desktop/electron/update-count.ts +++ b/apps/desktop/electron/update-count.ts @@ -17,7 +17,9 @@ function shouldCountCommits({ isShallow, hasMergeBase }) { // (developers / Docker dev images) keep the exact count path unchanged. function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMergeBase }) { if (!shouldCountCommits({ isShallow, hasMergeBase })) { - if (currentSha && targetSha && currentSha === targetSha) {return 0} + if (currentSha && targetSha && currentSha === targetSha) { + return 0 + } return 1 // behind by an unknown amount — show a generic "update available" } diff --git a/apps/desktop/electron/update-marker.test.ts b/apps/desktop/electron/update-marker.test.ts index 5207d7bf4f37..2910366f3f93 100644 --- a/apps/desktop/electron/update-marker.test.ts +++ b/apps/desktop/electron/update-marker.test.ts @@ -18,7 +18,13 @@ import test from 'node:test' import os from 'os' import path from 'path' -import { isPidAlive, markerPath, readLiveUpdateMarker, UPDATE_MARKER_MAX_AGE_MS, writeUpdateMarker } from './update-marker' +import { + isPidAlive, + markerPath, + readLiveUpdateMarker, + UPDATE_MARKER_MAX_AGE_MS, + writeUpdateMarker +} from './update-marker' function tmpHome(tag) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-marker-${tag}-`)) @@ -32,9 +38,9 @@ function writeMarker(home, pid, startedAtSec) { const ALIVE: typeof process.kill = () => true // injected kill that "succeeds" => pid alive -const DEAD : typeof process.kill= () => { - const err = new Error('no such process'); - (err as any).code = 'ESRCH' +const DEAD: typeof process.kill = () => { + const err = new Error('no such process') + ;(err as any).code = 'ESRCH' throw err } @@ -86,8 +92,8 @@ test('isPidAlive: own pid is alive, impossible pid is dead', () => { test('isPidAlive: EPERM counts as alive (process owned by another user)', () => { const eperm = () => { - const err = new Error('operation not permitted'); - (err as any).code = 'EPERM' + const err = new Error('operation not permitted') + ;(err as any).code = 'EPERM' throw err } diff --git a/apps/desktop/electron/update-marker.ts b/apps/desktop/electron/update-marker.ts index 6ecb3eb77d27..543fce0451d0 100644 --- a/apps/desktop/electron/update-marker.ts +++ b/apps/desktop/electron/update-marker.ts @@ -38,7 +38,9 @@ export function markerPath(hermesHome) { // EPERM => alive but owned by another user (still "alive" for our purposes). // Injectable `kill` keeps it unit-testable. export function isPidAlive(pid, kill: typeof process.kill = process.kill.bind(process)) { - if (!Number.isInteger(pid) || pid <= 0) {return false} + if (!Number.isInteger(pid) || pid <= 0) { + return false + } try { kill(pid, 0) @@ -61,9 +63,18 @@ export function isPidAlive(pid, kill: typeof process.kill = process.kill.bind(pr * Pure-ish: file I/O against the given path, plus an injectable pid probe and * clock for tests. */ -export function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPDATE_MARKER_MAX_AGE_MS }: { - now?: () => number, maxAgeMs?: number, kill?: typeof process.kill -} = {}) { +export function readLiveUpdateMarker( + hermesHome, + { + kill, + now = Date.now, + maxAgeMs = UPDATE_MARKER_MAX_AGE_MS + }: { + now?: () => number + maxAgeMs?: number + kill?: typeof process.kill + } = {} +) { const file = markerPath(hermesHome) let raw diff --git a/apps/desktop/electron/update-relaunch.test.ts b/apps/desktop/electron/update-relaunch.test.ts index f46e4f1a996c..d42582552d03 100644 --- a/apps/desktop/electron/update-relaunch.test.ts +++ b/apps/desktop/electron/update-relaunch.test.ts @@ -24,7 +24,8 @@ import os from 'node:os' import path from 'node:path' import test from 'node:test' -import { buildRelaunchScript, +import { + buildRelaunchScript, collectRelaunchArgs, collectRelaunchEnv, decideRelaunchOutcome, @@ -32,7 +33,8 @@ import { buildRelaunchScript, sandboxFallbackFromEnv, sandboxPreflight, shellQuote, - unpackedDirName } from './update-relaunch' + unpackedDirName +} from './update-relaunch' const ROOT = '/home/u/.hermes/hermes-agent' const UNPACKED = path.join(ROOT, 'apps', 'desktop', 'release', 'linux-unpacked') diff --git a/apps/desktop/electron/update-relaunch.ts b/apps/desktop/electron/update-relaunch.ts index 0db62c956157..cb1c09557681 100644 --- a/apps/desktop/electron/update-relaunch.ts +++ b/apps/desktop/electron/update-relaunch.ts @@ -39,9 +39,13 @@ import path from 'node:path' // Map process.platform → electron-builder's `release/<dir>-unpacked` name. function unpackedDirName(platform) { - if (platform === 'darwin') {return 'mac-unpacked'} // not used (mac swaps bundles) + if (platform === 'darwin') { + return 'mac-unpacked' + } // not used (mac swaps bundles) - if (platform === 'win32') {return 'win-unpacked'} + if (platform === 'win32') { + return 'win-unpacked' + } return 'linux-unpacked' } @@ -56,7 +60,9 @@ function unpackedDirName(platform) { * `.../release/linux-unpacked-evil` can't masquerade as `.../release/linux-unpacked`. */ function resolveUnpackedRelease(execPath, updateRoot, platform) { - if (!execPath || !updateRoot) {return null} + if (!execPath || !updateRoot) { + return null + } const releaseDir = path.join(updateRoot, 'apps', 'desktop', 'release') const unpacked = path.join(releaseDir, unpackedDirName(platform)) const normalizedExec = path.resolve(String(execPath)) @@ -83,9 +89,13 @@ function resolveUnpackedRelease(execPath, updateRoot, platform) { * app. Closeable manual-restart terminal state. */ function decideRelaunchOutcome({ underUnpacked, sandboxOk }) { - if (!underUnpacked) {return 'guiSkew'} + if (!underUnpacked) { + return 'guiSkew' + } - if (!sandboxOk) {return 'manual'} + if (!sandboxOk) { + return 'manual' + } return 'relaunch' } @@ -103,7 +113,9 @@ function decideRelaunchOutcome({ underUnpacked, sandboxOk }) { * `statSync` is injectable so this is testable without a real setuid file. */ function sandboxPreflight(unpackedDir, statSync) { - if (!unpackedDir) {return { ok: false, reason: 'no-unpacked-dir', path: null }} + if (!unpackedDir) { + return { ok: false, reason: 'no-unpacked-dir', path: null } + } const sandboxPath = path.join(unpackedDir, 'chrome-sandbox') let st @@ -126,7 +138,9 @@ function sandboxPreflight(unpackedDir, statSync) { return { ok: false, reason: 'not-root-not-setuid', path: sandboxPath } } - if (!ownedByRoot) {return { ok: false, reason: 'not-root', path: sandboxPath }} + if (!ownedByRoot) { + return { ok: false, reason: 'not-root', path: sandboxPath } + } return { ok: false, reason: 'not-setuid', path: sandboxPath } } @@ -148,9 +162,13 @@ function sandboxPreflight(unpackedDir, statSync) { function sandboxFallbackFromEnv(env, launchArgs) { const disable = String((env && env.ELECTRON_DISABLE_SANDBOX) || '').trim() - if (disable === '1' || disable.toLowerCase() === 'true') {return true} + if (disable === '1' || disable.toLowerCase() === 'true') { + return true + } - if (Array.isArray(launchArgs) && launchArgs.some(a => a === '--no-sandbox')) {return true} + if (Array.isArray(launchArgs) && launchArgs.some(a => a === '--no-sandbox')) { + return true + } return false } @@ -189,10 +207,14 @@ const INTERNAL_ARG_PREFIXES = [ * the exec path itself; there is no entry-script arg as in a dev run). */ function collectRelaunchArgs(argv) { - if (!Array.isArray(argv)) {return []} + if (!Array.isArray(argv)) { + return [] + } return argv.filter(arg => { - if (typeof arg !== 'string' || arg.length === 0) {return false} + if (typeof arg !== 'string' || arg.length === 0) { + return false + } return !INTERNAL_ARG_PREFIXES.some(prefix => prefix.endsWith('=') ? arg.startsWith(prefix) : arg === prefix || arg.startsWith(prefix + '=') @@ -213,10 +235,14 @@ const PRESERVED_ENV_PREFIXES = ['HERMES_DESKTOP_'] function collectRelaunchEnv(env) { const out = {} - if (!env || typeof env !== 'object') {return out} + if (!env || typeof env !== 'object') { + return out + } for (const [key, value] of Object.entries(env)) { - if (value == null) {continue} + if (value == null) { + continue + } if (PRESERVED_ENV_KEYS.includes(key) || PRESERVED_ENV_PREFIXES.some(p => key.startsWith(p))) { out[key] = String(value) @@ -270,7 +296,8 @@ exec ${shellQuote(execPath)}${quotedArgs ? ' ' + quotedArgs : ''} ` } -export { buildRelaunchScript, +export { + buildRelaunchScript, collectRelaunchArgs, collectRelaunchEnv, decideRelaunchOutcome, @@ -281,4 +308,5 @@ export { buildRelaunchScript, sandboxFallbackFromEnv, sandboxPreflight, shellQuote, - unpackedDirName } + unpackedDirName +} diff --git a/apps/desktop/electron/update-remote.test.ts b/apps/desktop/electron/update-remote.test.ts index c4e468a285bc..9244b363f298 100644 --- a/apps/desktop/electron/update-remote.test.ts +++ b/apps/desktop/electron/update-remote.test.ts @@ -18,11 +18,13 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { canonicalGitHubRemote, +import { + canonicalGitHubRemote, isOfficialSshRemote, isSshRemote, OFFICIAL_REPO_CANONICAL, - OFFICIAL_REPO_HTTPS_URL } from './update-remote' + OFFICIAL_REPO_HTTPS_URL +} from './update-remote' test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => { assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL) diff --git a/apps/desktop/electron/update-remote.ts b/apps/desktop/electron/update-remote.ts index b6b17ab0a7a7..c1f4b8274208 100644 --- a/apps/desktop/electron/update-remote.ts +++ b/apps/desktop/electron/update-remote.ts @@ -19,7 +19,9 @@ const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent' // no trailing slash, no .git suffix) so SSH and HTTPS forms of the same repo // compare equal. function canonicalGitHubRemote(url) { - if (!url) {return ''} + if (!url) { + return '' + } let value = String(url).trim() if (value.startsWith('git@github.com:')) { @@ -30,7 +32,9 @@ function canonicalGitHubRemote(url) { try { const parsed = new URL(value) - if (parsed.hostname && parsed.pathname) {value = `${parsed.hostname}${parsed.pathname}`} + if (parsed.hostname && parsed.pathname) { + value = `${parsed.hostname}${parsed.pathname}` + } } catch { // Leave non-URL forms unchanged. } @@ -38,7 +42,9 @@ function canonicalGitHubRemote(url) { value = value.trim().replace(/\/+$/, '') - if (value.endsWith('.git')) {value = value.slice(0, -4)} + if (value.endsWith('.git')) { + value = value.slice(0, -4) + } return value.toLowerCase() } @@ -55,8 +61,4 @@ function isOfficialSshRemote(url) { return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL } -export { canonicalGitHubRemote, - isOfficialSshRemote, - isSshRemote, - OFFICIAL_REPO_CANONICAL, - OFFICIAL_REPO_HTTPS_URL } +export { canonicalGitHubRemote, isOfficialSshRemote, isSshRemote, OFFICIAL_REPO_CANONICAL, OFFICIAL_REPO_HTTPS_URL } diff --git a/apps/desktop/electron/vscode-marketplace.ts b/apps/desktop/electron/vscode-marketplace.ts index 4ad72d343adc..3fe737dc9cb0 100644 --- a/apps/desktop/electron/vscode-marketplace.ts +++ b/apps/desktop/electron/vscode-marketplace.ts @@ -334,10 +334,4 @@ async function fetchMarketplaceThemes(id) { const __testing = { themeEntryName, looksLikeIconTheme } -export { - __testing, - extractThemes, - fetchMarketplaceThemes, - readCentralDirectory, - searchMarketplaceThemes -} +export { __testing, extractThemes, fetchMarketplaceThemes, readCentralDirectory, searchMarketplaceThemes } diff --git a/apps/desktop/electron/window-state.test.ts b/apps/desktop/electron/window-state.test.ts index 83765270675b..25a4fcaa1196 100644 --- a/apps/desktop/electron/window-state.test.ts +++ b/apps/desktop/electron/window-state.test.ts @@ -7,14 +7,16 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { computeWindowOptions, +import { + computeWindowOptions, debounce, DEFAULT_HEIGHT, DEFAULT_WIDTH, MIN_HEIGHT, MIN_WIDTH, onScreen, - sanitizeWindowState } from './window-state' + sanitizeWindowState +} from './window-state' // A single 1920×1080 monitor (work area trimmed for the taskbar). const PRIMARY = [{ workArea: { x: 0, y: 0, width: 1920, height: 1040 } }] diff --git a/apps/desktop/electron/window-state.ts b/apps/desktop/electron/window-state.ts index 919d3274547a..d443c6aaf183 100644 --- a/apps/desktop/electron/window-state.ts +++ b/apps/desktop/electron/window-state.ts @@ -21,26 +21,29 @@ const MIN_VISIBLE = 48 const finite = v => typeof v === 'number' && Number.isFinite(v) const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi)) -interface SanitizedWindowState{ - width: number, height: number, isMaximized: boolean, x?: number,y?: number +interface SanitizedWindowState { + width: number + height: number + isMaximized: boolean + x?: number + y?: number } // Parse raw JSON → clean state, or null if garbage. width/height are required // and floored; x/y survive only as a finite pair; isMaximized is strict. -function sanitizeWindowState(raw?: any): SanitizedWindowState | null - - - { - if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) {return null} +function sanitizeWindowState(raw?: any): SanitizedWindowState | null { + if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) { + return null + } const state: SanitizedWindowState = { width: Math.max(MIN_WIDTH, Math.round(raw.width)), height: Math.max(MIN_HEIGHT, Math.round(raw.height)), - isMaximized: raw.isMaximized === true, + isMaximized: raw.isMaximized === true } if (finite(raw.x) && finite(raw.y)) { - state.x = Math.round(raw.x); + state.x = Math.round(raw.x) state.y = Math.round(raw.y) } @@ -50,10 +53,14 @@ function sanitizeWindowState(raw?: any): SanitizedWindowState | null // True when `bounds` overlaps some display's work area by ≥ MIN_VISIBLE on both // axes. `displays` is Electron's screen.getAllDisplays() shape. function onScreen(bounds, displays) { - if (!Array.isArray(displays)) {return false} + if (!Array.isArray(displays)) { + return false + } return displays.some(({ workArea: a } = {}) => { - if (!a) {return false} + if (!a) { + return false + } const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x) const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y) @@ -97,7 +104,7 @@ function computeWindowOptions(state, displays): WindowOptions { finite(state.y) && onScreen({ x: state.x, y: state.y, width: opts.width, height: opts.height }, displays) ) { - opts.x = state.x; + opts.x = state.x opts.y = state.y } @@ -127,7 +134,8 @@ function debounce(fn, delayMs) { return debounced } -export { computeWindowOptions, +export { + computeWindowOptions, debounce, DEFAULT_HEIGHT, DEFAULT_WIDTH, @@ -135,4 +143,5 @@ export { computeWindowOptions, MIN_VISIBLE, MIN_WIDTH, onScreen, - sanitizeWindowState } + sanitizeWindowState +} diff --git a/apps/desktop/electron/windows-child-process.test.ts b/apps/desktop/electron/windows-child-process.test.ts index d1b4242f76cd..f1d72dedc97a 100644 --- a/apps/desktop/electron/windows-child-process.test.ts +++ b/apps/desktop/electron/windows-child-process.test.ts @@ -9,7 +9,6 @@ const ELECTRON_DIR = path.dirname(fileURLToPath(import.meta.url)) // TODO FIXME these tests all grep source code for specific things. This is an antipattern. // Tests should NEVER read src, only assert behavior. - function readElectronFile(name) { return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n') } diff --git a/apps/desktop/electron/windows-user-env.ts b/apps/desktop/electron/windows-user-env.ts index a6bc99b08306..fa91590be41b 100644 --- a/apps/desktop/electron/windows-user-env.ts +++ b/apps/desktop/electron/windows-user-env.ts @@ -20,7 +20,9 @@ import { execFileSync } from 'node:child_process' // Returns the raw value string (spaces inside the value preserved), or null when // the requested value line isn't present. function parseRegQueryValue(stdout, name) { - if (!stdout || !name) {return null} + if (!stdout || !name) { + return null + } const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/ for (const rawLine of String(stdout).split(/\r?\n/)) { @@ -39,7 +41,9 @@ function parseRegQueryValue(stdout, name) { // unexpanded references; plain REG_SZ paths have none, so this is a no-op for // the common F:\... case. Unknown references are left verbatim. function expandWindowsEnvRefs(value, env = process.env) { - if (!value) {return value} + if (!value) { + return value + } return value.replace(/%([^%]+)%/g, (whole, name) => { const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase()) @@ -51,10 +55,21 @@ function expandWindowsEnvRefs(value, env = process.env) { // Read a User-scoped env var from HKCU\Environment. Windows-only: returns null // off-Windows (without spawning), on any spawn error, when `reg` exits non-zero // (the value doesn't exist), or when the value is empty. -function readWindowsUserEnvVar(name, { platform = process.platform, env = process.env, exec = execFileSync }: { - platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv, exec?: typeof execFileSync | ((file?: string, args?: any) => string) -} = {}) { - if (platform !== 'win32' || !name) {return null} +function readWindowsUserEnvVar( + name, + { + platform = process.platform, + env = process.env, + exec = execFileSync + }: { + platform?: NodeJS.Platform + env?: NodeJS.ProcessEnv + exec?: typeof execFileSync | ((file?: string, args?: any) => string) + } = {} +) { + if (platform !== 'win32' || !name) { + return null + } let stdout try { @@ -70,12 +85,12 @@ function readWindowsUserEnvVar(name, { platform = process.platform, env = proces const raw = parseRegQueryValue(stdout, name) - if (raw == null) {return null} + if (raw == null) { + return null + } const expanded = expandWindowsEnvRefs(raw, env).trim() return expanded || null } -export { expandWindowsEnvRefs, - parseRegQueryValue, - readWindowsUserEnvVar } +export { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } diff --git a/apps/desktop/electron/workspace-cwd.ts b/apps/desktop/electron/workspace-cwd.ts index 332ac92dca6c..79ea87bee439 100644 --- a/apps/desktop/electron/workspace-cwd.ts +++ b/apps/desktop/electron/workspace-cwd.ts @@ -1,7 +1,7 @@ import path from 'node:path' /** True when `dir` lives inside a packaged app bundle / install tree. */ -function isPackagedInstallPath(dir, { installRoots, isPackaged }: { installRoots: string[], isPackaged:boolean }) { +function isPackagedInstallPath(dir, { installRoots, isPackaged }: { installRoots: string[]; isPackaged: boolean }) { if (!isPackaged || !dir) { return false } diff --git a/apps/desktop/electron/wsl-clipboard-image.test.ts b/apps/desktop/electron/wsl-clipboard-image.test.ts index da3481ffa176..244db40f2a91 100644 --- a/apps/desktop/electron/wsl-clipboard-image.test.ts +++ b/apps/desktop/electron/wsl-clipboard-image.test.ts @@ -1,10 +1,12 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { decodeClipboardImageBase64, +import { + decodeClipboardImageBase64, encodePowerShellCommand, powershellCandidates, - readWslWindowsClipboardImage } from './wsl-clipboard-image' + readWslWindowsClipboardImage +} from './wsl-clipboard-image' const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) @@ -66,11 +68,11 @@ test('readWslWindowsClipboardImage decodes the first candidate that returns a PN test('readWslWindowsClipboardImage returns null and stops when stdout is empty (no image)', () => { let count = 0 - const exec =(() => { + const exec = (() => { count += 1 - return '' - } )as any + return '' + }) as any const result = readWslWindowsClipboardImage({ exec, diff --git a/apps/desktop/electron/wsl-clipboard-image.ts b/apps/desktop/electron/wsl-clipboard-image.ts index 23fe45e8b157..2859f389c021 100644 --- a/apps/desktop/electron/wsl-clipboard-image.ts +++ b/apps/desktop/electron/wsl-clipboard-image.ts @@ -34,7 +34,9 @@ function powershellCandidates() { function decodeClipboardImageBase64(stdout) { const b64 = String(stdout || '').trim() - if (!b64) {return null} + if (!b64) { + return null + } let buffer @@ -57,7 +59,10 @@ function decodeClipboardImageBase64(stdout) { // Read the Windows clipboard image from inside WSL. Returns a PNG Buffer, or // null when there's no image, PowerShell is unreachable, or output is invalid. // Linux-only by contract (caller gates on IS_WSL); never throws. -function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powershellCandidates() }: {exec?: typeof execFileSync, candidates?: string[]} = {}) { +function readWslWindowsClipboardImage({ + exec = execFileSync, + candidates = powershellCandidates() +}: { exec?: typeof execFileSync; candidates?: string[] } = {}) { const encoded = encodePowerShellCommand(PS_SCRIPT) for (const ps of candidates) { @@ -78,10 +83,14 @@ function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powers const decoded = decodeClipboardImageBase64(stdout) - if (decoded) {return decoded} + if (decoded) { + return decoded + } // Empty stdout = no image on the clipboard; stop, don't try fallbacks. - if (String(stdout || '').trim() === '') {return null} + if (String(stdout || '').trim() === '') { + return null + } } catch { // This powershell.exe candidate is missing/failed — try the next one. } @@ -90,7 +99,4 @@ function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powers return null } -export { decodeClipboardImageBase64, - encodePowerShellCommand, - powershellCandidates, - readWslWindowsClipboardImage } +export { decodeClipboardImageBase64, encodePowerShellCommand, powershellCandidates, readWslWindowsClipboardImage } diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts index 1f865cfbfa43..f3518f583d62 100644 --- a/apps/desktop/electron/zoom.ts +++ b/apps/desktop/electron/zoom.ts @@ -13,7 +13,9 @@ const MIN_ZOOM_LEVEL = -9 const MAX_ZOOM_LEVEL = 9 export function clampZoomLevel(value) { - if (!Number.isFinite(value)) {return 0} + if (!Number.isFinite(value)) { + return 0 + } return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) } @@ -23,7 +25,9 @@ export function zoomLevelToPercent(level) { } export function percentToZoomLevel(percent) { - if (!Number.isFinite(percent) || percent <= 0) {return 0} + if (!Number.isFinite(percent) || percent <= 0) { + return 0 + } return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE)) -} \ No newline at end of file +} diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index 5ae0cd424747..70a6a0338ca3 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -216,7 +216,10 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . const titles = [...new Set(artifacts.map(artifact => artifact.sessionTitle).filter(Boolean))].slice(0, 2) - const hints = [...extensions.map(ext => t.common.tryHint(`.${ext}`)), ...titles.map(title => t.common.tryHint(title))] + const hints = [ + ...extensions.map(ext => t.common.tryHint(`.${ext}`)), + ...titles.map(title => t.common.tryHint(title)) + ] return hints.length > 0 ? hints : undefined }, [artifacts, t]) diff --git a/apps/desktop/src/app/skills/hub.tsx b/apps/desktop/src/app/skills/hub.tsx index a7ed3ca8fe5b..1af1b672d26f 100644 --- a/apps/desktop/src/app/skills/hub.tsx +++ b/apps/desktop/src/app/skills/hub.tsx @@ -323,7 +323,9 @@ export function SkillsHub({ query }: SkillsHubProps) { <div className="flex shrink-0 items-center justify-between gap-3 px-4 pb-1.5 text-[0.68rem] text-(--ui-text-tertiary)"> <span className="min-w-0 truncate"> {term.length > 0 ? h.resultCount(results.length, null) : h.featured} - {anyFetching && results.length > 0 && <span className="ml-2 text-(--ui-text-quaternary)">{h.searching}</span>} + {anyFetching && results.length > 0 && ( + <span className="ml-2 text-(--ui-text-quaternary)">{h.searching}</span> + )} </span> {hasInstalled && ( diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index 41620e2c1243..ccbcd5c9b41e 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -492,7 +492,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p try { await editLearningNode(skillEditor.name, skillDraft) - notify({ kind: 'success', title: t.skills.skillUpdated, message: t.skills.appliesToNewSessions(skillEditor.name) }) + notify({ + kind: 'success', + title: t.skills.skillUpdated, + message: t.skills.appliesToNewSessions(skillEditor.name) + }) setSkillEditor(null) void refreshCapabilities() } catch (err) { @@ -577,7 +581,9 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p left={sortButton(skillsSortDesc, () => $skillsSortDesc.set(!$skillsSortDesc.get()))} right={ <ListStripMenu - items={[{ disabled: bulkBusy, label: t.skills.disableUnused, onSelect: () => void disableUnused() }]} + items={[ + { disabled: bulkBusy, label: t.skills.disableUnused, onSelect: () => void disableUnused() } + ]} label={t.skills.tabSkills} toggle={bulkSwitch(allSkillsEnabled)} /> diff --git a/apps/desktop/src/components/ui/copy-button.tsx b/apps/desktop/src/components/ui/copy-button.tsx index ff7663ff94a2..cee6edcde884 100644 --- a/apps/desktop/src/components/ui/copy-button.tsx +++ b/apps/desktop/src/components/ui/copy-button.tsx @@ -233,5 +233,11 @@ export function CopyButton({ ) // Only icon-only buttons need a tooltip; the text variant already shows its label. - return appearance === 'icon' ? <Tip label={feedbackLabel} side={side ?? 'bottom'}>{button}</Tip> : button + return appearance === 'icon' ? ( + <Tip label={feedbackLabel} side={side ?? 'bottom'}> + {button} + </Tip> + ) : ( + button + ) } diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 095bf6e286f6..5dbec605e906 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -281,7 +281,8 @@ export const zhHant = defineLocale({ toolViewTitle: '工具呼叫顯示', toolViewDesc: '產品模式會隱藏原始工具 payload;技術模式會顯示完整輸入/輸出。', uiScaleTitle: '介面縮放', - uiScaleDesc: (percent: number) => `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`, + uiScaleDesc: (percent: number) => + `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`, translucencyTitle: '視窗透明', translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。', embedsTitle: '內嵌預覽', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 411bfcf6ac14..84b9f54e281d 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -372,7 +372,8 @@ export const zh: Translations = { toolViewTitle: '工具调用显示', toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。', uiScaleTitle: '界面缩放', - uiScaleDesc: (percent: number) => `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`, + uiScaleDesc: (percent: number) => + `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`, translucencyTitle: '窗口透明', translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。', embedsTitle: '内嵌预览', From 513dba42e6a066fd55edd7833bf807afee8da637 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:04:29 -0700 Subject: [PATCH 272/610] chore(models): drop x-ai/grok-4.3 from OpenRouter/Nous curated lists in favor of grok-4.5 (#61097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grok-4.5 is GA and is now the single curated Grok entry on the aggregator lists. grok-4.3 is NOT retired upstream — it remains fully usable by typing the model name (validated against the live catalogs); this only removes it from the short curated picker snapshots. The xAI-direct list is models.dev-cache-driven and unaffected. --- hermes_cli/models.py | 2 -- website/static/api/model-catalog.json | 9 +-------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index a103be30ac58..e82993ac42d7 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -49,7 +49,6 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ ("google/gemini-3.5-flash", ""), # xAI ("x-ai/grok-4.5", ""), - ("x-ai/grok-4.3", ""), # DeepSeek ("deepseek/deepseek-v4-pro", ""), ("deepseek/deepseek-v4-flash", ""), @@ -195,7 +194,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "google/gemini-3.5-flash", # xAI "x-ai/grok-4.5", - "x-ai/grok-4.3", # DeepSeek "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index 96009ade3169..99aeb4262a8f 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-07-08T18:59:16Z", + "updated_at": "2026-07-08T19:45:27Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" @@ -60,10 +60,6 @@ "id": "x-ai/grok-4.5", "description": "" }, - { - "id": "x-ai/grok-4.3", - "description": "" - }, { "id": "deepseek/deepseek-v4-pro", "description": "" @@ -193,9 +189,6 @@ { "id": "x-ai/grok-4.5" }, - { - "id": "x-ai/grok-4.3" - }, { "id": "deepseek/deepseek-v4-pro" }, From bf913abc2efcfb6379296007db46e6f0e3dc1738 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Wed, 8 Jul 2026 22:40:51 -0500 Subject: [PATCH 273/610] fix(desktop): stop using tsx to boot Electron main in dev Electron 40 ships Node 24.15, where tsx's ESM load hook returns null and crashes with ERR_INVALID_RETURN_PROPERTY_VALUE. Bundle main+preload via esbuild for `npm run dev` and always load the JS preload from dist/. --- apps/desktop/electron/main.ts | 9 ++++----- apps/desktop/package.json | 6 +++--- apps/desktop/scripts/bundle-electron-main.mjs | 19 ++++++++++++------- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index f569bf2dd458..b8c2d03da380 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -144,11 +144,10 @@ const IS_WSL = isWslEnvironment() const DARWIN_MAJOR = IS_MAC ? Number.parseInt(os.release(), 10) || 0 : 0 const APP_ROOT = app.getAppPath() -// Preload script path: in dev we load the .ts source directly (tsx handles -// the transform); in prod we load the bundled .js from dist/. -const PRELOAD_PATH = IS_PACKAGED - ? path.join(APP_ROOT, 'dist', 'electron-preload.js') - : path.join(import.meta.dirname, 'preload.ts') +// Preload must be plain JS — Electron's sandbox can't run .ts, and tsx's +// ESM loader is broken on Electron 40's Node (ERR_INVALID_RETURN_PROPERTY_VALUE). +// Dev (`npm run dev`) and prod both load the esbuild output from dist/. +const PRELOAD_PATH = path.join(APP_ROOT, 'dist', 'electron-preload.js') function hiddenWindowsChildOptions(options: any = {}): ExecFileSyncOptionsWithStringEncoding { if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9bad2161cb01..fe91d2991c09 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -14,9 +14,9 @@ "dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"", "dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev", "dev:renderer": "node scripts/assert-root-install.mjs && vite --host 127.0.0.1 --port 5174", - "dev:electron": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 NODE_OPTIONS=tsx electron electron/main.ts", - "profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 NODE_OPTIONS=tsx electron --inspect=9229 electron/main.ts", - "profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof NODE_OPTIONS=tsx HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron electron/main.ts", + "dev:electron": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", + "profile:main": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", + "profile:main:cpu": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "start": "npm run build && electron .", "build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs", "postbuild": "node scripts/assert-dist-built.mjs", diff --git a/apps/desktop/scripts/bundle-electron-main.mjs b/apps/desktop/scripts/bundle-electron-main.mjs index f950f4908a20..316d2ce1b185 100644 --- a/apps/desktop/scripts/bundle-electron-main.mjs +++ b/apps/desktop/scripts/bundle-electron-main.mjs @@ -25,9 +25,14 @@ const preloadEntry = resolve(root, 'electron/preload.ts') const preloadOut = resolve(distDir, 'electron-preload.js') const external = ['electron', 'node-pty', 'fs'] - const define = { - 'process.env.HERMES_DESKTOP_IS_PACKAGED': JSON.stringify(true) - } +// Production bundles bake packaged=true so unpackaged `electron .` still +// behaves like a packaged build. Dev bundles (`--dev`) leave the env alone +// so HERMES_DESKTOP_DEV_SERVER / source-tree resolution keep working. +const isDev = process.argv.includes('--dev') +const define = isDev + ? {} + : { 'process.env.HERMES_DESKTOP_IS_PACKAGED': JSON.stringify(true) } + // Bundle main.ts → dist/electron-main.mjs await build({ entryPoints: [mainEntry], @@ -37,15 +42,15 @@ await build({ target: 'node20', outfile: mainOut, external, - banner: { + banner: { js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);", }, define, logLevel: 'info', }) -console.log(`bundled ${mainOut}`) +console.log(`bundled ${mainOut}${isDev ? ' (dev)' : ''}`) -// Bundle preload.ts → dist/electron-preload.cjs +// Bundle preload.ts → dist/electron-preload.js await build({ entryPoints: [preloadEntry], bundle: true, @@ -57,4 +62,4 @@ await build({ define, logLevel: 'info', }) -console.log(`bundled ${preloadOut}`) +console.log(`bundled ${preloadOut}${isDev ? ' (dev)' : ''}`) From 3e24b16f566045399012bc1185fe0cdb6e1a1be9 Mon Sep 17 00:00:00 2001 From: Shannon Sands <shannon.sands.1979@gmail.com> Date: Thu, 9 Jul 2026 14:11:11 +1000 Subject: [PATCH 274/610] fix(dashboard): support mobile OAuth login --- hermes_cli/dashboard_auth/middleware.py | 6 +- hermes_cli/dashboard_auth/routes.py | 8 ++ hermes_cli/web_server.py | 50 +++++++- .../test_dashboard_auth_password_login.py | 18 +++ tests/hermes_cli/test_web_oauth_dispatch.py | 50 ++++++++ web/src/components/OAuthLoginModal.tsx | 78 +++++++----- web/src/i18n/en.ts | 7 +- web/src/i18n/types.ts | 2 + web/src/lib/api.test.ts | 82 +++++++++++-- web/src/lib/api.ts | 68 +++-------- web/src/lib/clipboard.test.ts | 114 ++++++++++++++++++ web/src/lib/clipboard.ts | 56 +++++++++ 12 files changed, 446 insertions(+), 93 deletions(-) create mode 100644 web/src/lib/clipboard.test.ts create mode 100644 web/src/lib/clipboard.ts diff --git a/hermes_cli/dashboard_auth/middleware.py b/hermes_cli/dashboard_auth/middleware.py index 2c5f5b4f7b95..362ed729d65b 100644 --- a/hermes_cli/dashboard_auth/middleware.py +++ b/hermes_cli/dashboard_auth/middleware.py @@ -150,6 +150,8 @@ def _auto_sso_response(request: Request) -> Response | None: * exactly ONE interactive provider is registered — with two or more we can't pick for the user, so the ``/login`` chooser must render; with zero there's nothing to redirect to; + * that provider is OAuth-style, not a password form provider. Password + providers must render ``/login`` so the user can enter credentials; * the one-shot loop-guard marker is ABSENT. Its presence means we already bounced to the portal once and came back still unauthenticated (no portal session) — auto-redirecting again would @@ -185,6 +187,9 @@ def _auto_sso_response(request: Request) -> Response | None: from hermes_cli.dashboard_auth.prefix import prefix_from_request provider = providers[0] + if getattr(provider, "supports_password", False): + return None + prefix = prefix_from_request(request) next_param = _safe_next_target(request) from urllib.parse import quote @@ -458,4 +463,3 @@ def _attempt_refresh(request: Request, *, refresh_token): if new_session is not None: return new_session, provider.name return None - diff --git a/hermes_cli/dashboard_auth/routes.py b/hermes_cli/dashboard_auth/routes.py index 9e80f2583c5b..ee595be7d821 100644 --- a/hermes_cli/dashboard_auth/routes.py +++ b/hermes_cli/dashboard_auth/routes.py @@ -192,6 +192,14 @@ async def auth_login(request: Request, provider: str, next: str = ""): status_code=404, detail=f"Provider does not support interactive login: {provider!r}", ) + if getattr(p, "supports_password", False): + from urllib.parse import quote + + safe_next = _validate_post_login_target(next) + login_url = f"{_prefix(request)}/login" + if safe_next: + login_url = f"{login_url}?next={quote(safe_next, safe='')}" + return RedirectResponse(url=login_url, status_code=302) try: ls = p.start_login(redirect_uri=_redirect_uri(request)) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index db6b6a1ec8de..fa270747528e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -9087,6 +9087,54 @@ def _xai_device_poller(session_id: str) -> None: sess["error_message"] = str(e) +def _http_response_error_detail(resp: Any) -> str: + """Best-effort extraction of a short provider error detail.""" + try: + payload = resp.json() + except Exception: + payload = None + if isinstance(payload, dict): + error = payload.get("error") + if isinstance(error, dict): + parts = [ + str(error.get(key, "")).strip() + for key in ("message", "error_description", "code", "type") + if str(error.get(key, "")).strip() + ] + if parts: + return ": ".join(parts) + if isinstance(error, str) and error.strip(): + return error.strip() + for key in ("detail", "message", "error_description"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + text = str(getattr(resp, "text", "") or "").strip() + return text[:500] + + +def _codex_device_code_start_error(resp: Any) -> str: + """Dashboard-facing OpenAI Codex device-code start failure.""" + status = getattr(resp, "status_code", "unknown") + detail = _http_response_error_detail(resp) + lower = detail.lower() + if "device" in lower and ("authori" in lower or "enable" in lower): + message = ( + "OpenAI rejected the device-code login request. Your OpenAI " + "account may need device-code authorization enabled before Hermes " + "can start this dashboard login. Enable device-code authorization " + "in OpenAI, then return here and click Login again." + ) + else: + message = ( + "OpenAI rejected the device-code login request. Please try Login " + "again from the dashboard after checking your OpenAI account settings." + ) + if detail: + return f"{message} (HTTP {status}: {detail})" + return f"{message} (HTTP {status})" + + def _codex_full_login_worker(session_id: str) -> None: """Run the complete OpenAI Codex device-code flow. @@ -9119,7 +9167,7 @@ def _codex_full_login_worker(session_id: str) -> None: headers={"Content-Type": "application/json"}, ) if resp.status_code != 200: - raise RuntimeError(f"deviceauth/usercode returned {resp.status_code}") + raise RuntimeError(_codex_device_code_start_error(resp)) device_data = resp.json() user_code = device_data.get("user_code", "") device_auth_id = device_data.get("device_auth_id", "") diff --git a/tests/hermes_cli/test_dashboard_auth_password_login.py b/tests/hermes_cli/test_dashboard_auth_password_login.py index b9508c34e491..1fa59480115b 100644 --- a/tests/hermes_cli/test_dashboard_auth_password_login.py +++ b/tests/hermes_cli/test_dashboard_auth_password_login.py @@ -198,6 +198,24 @@ class TestProviderListFlag: prov = {p["name"]: p for p in resp.json()["providers"]} assert prov["testpw"]["supports_password"] is True + def test_password_provider_html_redirects_to_login_form(self, gated_app): + resp = gated_app.get("/", follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["location"] == "/login?next=%2F" + + login = gated_app.get(resp.headers["location"]) + assert login.status_code == 200 + assert '<form class="provider-form" data-provider="testpw"' in login.text + assert "/auth/password-login" in login.text + + def test_password_provider_auth_login_redirects_to_login_form(self, gated_app): + resp = gated_app.get( + "/auth/login?provider=testpw&next=%2F", + follow_redirects=False, + ) + assert resp.status_code == 302 + assert resp.headers["location"] == "/login?next=%2F" + def test_oauth_provider_reports_false(self): clear_providers() register_provider(StubAuthProvider()) diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index f8ee073b138c..46d770410fd9 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -340,6 +340,56 @@ def test_codex_dashboard_worker_persists_inside_session_profile(tmp_path, monkey ws._oauth_sessions.pop(sid, None) +def test_codex_dashboard_start_rewords_device_authorization_error(monkeypatch): + from hermes_cli import web_server as ws + + before_sessions = set(ws._oauth_sessions) + + class _Resp: + status_code = 400 + text = "Enable device code authorization" + + def json(self): + return { + "error": { + "message": "Enable device code authorization", + "code": "device_authorization_not_enabled", + } + } + + class _Client: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def post(self, url, **kwargs): + assert url.endswith("/deviceauth/usercode") + return _Resp() + + monkeypatch.setattr(httpx, "Client", _Client) + + try: + resp = client.post( + "/api/providers/oauth/openai-codex/start", + headers=HEADERS, + ) + + assert resp.status_code == 500 + detail = resp.json()["detail"] + assert "OpenAI rejected the device-code login request" in detail + assert "Enable device-code authorization in OpenAI" in detail + assert "click Login again" in detail + assert "hermes auth" not in detail + finally: + for sid in set(ws._oauth_sessions) - before_sessions: + ws._oauth_sessions.pop(sid, None) + + def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch): from hermes_cli import auth as auth_mod from hermes_cli import web_server as ws diff --git a/web/src/components/OAuthLoginModal.tsx b/web/src/components/OAuthLoginModal.tsx index f35fd81575b7..2d1b07ac0295 100644 --- a/web/src/components/OAuthLoginModal.tsx +++ b/web/src/components/OAuthLoginModal.tsx @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from "react"; -import { ExternalLink, X, Check } from "lucide-react"; +import { ExternalLink, X, Check, Copy } from "lucide-react"; import { Button } from "@nous-research/ui/ui/components/button"; -import { CopyButton } from "@nous-research/ui/ui/components/command-block"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api"; +import { copyTextToClipboard } from "@/lib/clipboard"; import { Input } from "@nous-research/ui/ui/components/input"; import { useI18n } from "@/i18n"; import { cn, themedBody } from "@/lib/utils"; @@ -30,9 +30,13 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) { const [start, setStart] = useState<OAuthStartResponse | null>(null); const [pkceCode, setPkceCode] = useState(""); const [errorMsg, setErrorMsg] = useState<string | null>(null); + const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">( + "idle", + ); const [secondsLeft, setSecondsLeft] = useState<number | null>(null); const isMounted = useRef(true); const pollTimer = useRef<number | null>(null); + const copyResetTimer = useRef<number | null>(null); const { t } = useI18n(); // Initiate flow on mount @@ -59,6 +63,8 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) { return () => { isMounted.current = false; if (pollTimer.current !== null) window.clearInterval(pollTimer.current); + if (copyResetTimer.current !== null) + window.clearTimeout(copyResetTimer.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -162,6 +168,24 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) { return `${m}:${String(r).padStart(2, "0")}`; }; + const handleCopyDeviceCode = async (code: string) => { + if (copyResetTimer.current !== null) { + window.clearTimeout(copyResetTimer.current); + copyResetTimer.current = null; + } + const copied = await copyTextToClipboard(code); + if (!isMounted.current) return; + setCopyStatus(copied ? "copied" : "failed"); + copyResetTimer.current = window.setTimeout(() => { + if (isMounted.current) setCopyStatus("idle"); + copyResetTimer.current = null; + }, 2000); + }; + + const deviceCode = start?.flow === "device_code" ? start.user_code : ""; + const verificationUrl = + start?.flow === "device_code" ? start.verification_url : ""; + return ( <div className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4" @@ -262,35 +286,35 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) { </p> <div className="flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4"> <code className="font-mono-ui text-2xl tracking-widest text-foreground"> - { - ( - start as Extract< - OAuthStartResponse, - { flow: "device_code" } - > - ).user_code - } + {deviceCode} </code> - <CopyButton - text={ - ( - start as Extract< - OAuthStartResponse, - { flow: "device_code" } - > - ).user_code + <Button + size="sm" + outlined + className="shrink-0 uppercase" + onClick={() => void handleCopyDeviceCode(deviceCode)} + prefix={ + copyStatus === "copied" ? ( + <Check className="h-4 w-4" /> + ) : ( + <Copy className="h-4 w-4" /> + ) } - /> + aria-label={t.oauth.copyCode ?? "Copy code"} + > + {copyStatus === "copied" + ? t.oauth.copied + : (t.oauth.copyCode ?? "Copy code")} + </Button> </div> + {copyStatus === "failed" && ( + <p className="text-xs text-destructive"> + {t.oauth.copyFailed ?? + "Could not copy automatically. Select the code and copy it manually."} + </p> + )} <a - href={ - ( - start as Extract< - OAuthStartResponse, - { flow: "device_code" } - > - ).verification_url - } + href={verificationUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1" diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index e2ba0cc03692..a8be9310835a 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -501,16 +501,19 @@ export const en: Translations = { oauth: { title: "Provider Logins (OAuth)", providerLogins: "Provider Logins (OAuth)", - description: "{connected} of {total} OAuth providers connected. Login flows currently run via the CLI; click Copy command and paste into a terminal to set up.", + description: + "{connected} of {total} OAuth providers connected. Use Login for dashboard-supported flows; CLI commands remain available for external or fallback setup.", connected: "Connected", expired: "Expired", - notConnected: "Not connected. Run {command} in a terminal.", + notConnected: "Not connected. Use Login when available, or run {command} in a terminal.", runInTerminal: "in a terminal.", noProviders: "No OAuth-capable providers detected.", login: "Login", disconnect: "Disconnect", managedExternally: "Managed externally", copied: "Copied ✓", + copyCode: "Copy code", + copyFailed: "Could not copy automatically. Select the code and copy it manually.", cli: "Copy", copyCliCommand: "Copy CLI command (for external / fallback)", connect: "Connect", diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index a93f921cadf0..9898891dc04a 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -530,6 +530,8 @@ export interface Translations { disconnect: string; managedExternally: string; copied: string; + copyCode?: string; + copyFailed?: string; cli: string; copyCliCommand: string; connect: string; diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index 4da9234ac5d7..4d63d51a01a3 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -2,21 +2,28 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { api } from "./api"; +const SESSION_HEADER = "X-Hermes-Session-Token"; + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); +function jsonFetchMock(body: unknown = { ok: true }) { + return vi.fn<typeof fetch>( + async () => + new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + status: 200, + }), + ); +} + describe("api.getModelOptions", () => { it("requests a live model refresh when asked", async () => { vi.stubGlobal("window", {}); - const fetchMock = vi.fn(async () => - new Response(JSON.stringify({ providers: [] }), { - headers: { "Content-Type": "application/json" }, - status: 200, - }), - ); + const fetchMock = jsonFetchMock({ providers: [] }); vi.stubGlobal("fetch", fetchMock); await api.getModelOptions({ refresh: true }); @@ -30,12 +37,7 @@ describe("api.getModelOptions", () => { it("keeps explicit profile scoping when refreshing", async () => { vi.stubGlobal("window", {}); - const fetchMock = vi.fn(async () => - new Response(JSON.stringify({ providers: [] }), { - headers: { "Content-Type": "application/json" }, - status: 200, - }), - ); + const fetchMock = jsonFetchMock({ providers: [] }); vi.stubGlobal("fetch", fetchMock); await api.getModelOptions({ profile: "default", refresh: true }); @@ -46,3 +48,59 @@ describe("api.getModelOptions", () => { ); }); }); + +describe("api OAuth helpers", () => { + it("starts OAuth login in gated mode without requiring an injected session token", async () => { + vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true }); + const fetchMock = jsonFetchMock({ + flow: "device_code", + session_id: "oauth-session", + }); + vi.stubGlobal("fetch", fetchMock); + + await api.startOAuthLogin("openai-codex"); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/providers/oauth/openai-codex/start", + expect.objectContaining({ + body: "{}", + credentials: "include", + method: "POST", + }), + ); + const headers = fetchMock.mock.calls[0][1]?.headers as Headers; + expect(headers.get("Content-Type")).toBe("application/json"); + expect(headers.has(SESSION_HEADER)).toBe(false); + }); + + it("still sends the injected session token for OAuth login in loopback mode", async () => { + vi.stubGlobal("window", { __HERMES_SESSION_TOKEN__: "loopback-token" }); + const fetchMock = jsonFetchMock({ + flow: "device_code", + session_id: "oauth-session", + }); + vi.stubGlobal("fetch", fetchMock); + + await api.startOAuthLogin("openai-codex"); + + const headers = fetchMock.mock.calls[0][1]?.headers as Headers; + expect(headers.get(SESSION_HEADER)).toBe("loopback-token"); + }); + + it("runs provider auth mutations in gated mode via cookie auth", async () => { + vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true }); + const fetchMock = jsonFetchMock({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + await api.disconnectOAuthProvider("anthropic"); + await api.submitOAuthCode("anthropic", "oauth-session", "code-123"); + await api.cancelOAuthSession("oauth-session"); + await api.revealEnvVar("OPENAI_API_KEY"); + + for (const call of fetchMock.mock.calls) { + const init = call[1] as RequestInit; + expect(init.credentials).toBe("include"); + expect((init.headers as Headers).has(SESSION_HEADER)).toBe(false); + } + }); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e63208129820..457eb10bb93f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -34,7 +34,6 @@ declare global { __HERMES_AUTH_REQUIRED__?: boolean; } } -let _sessionToken: string | null = null; const SESSION_HEADER = "X-Hermes-Session-Token"; function setSessionHeader(headers: Headers, token: string): void { @@ -197,16 +196,6 @@ function pluginPath(name: string): string { return name.split("/").map(encodeURIComponent).join("/"); } -async function getSessionToken(): Promise<string> { - if (_sessionToken) return _sessionToken; - const injected = window.__HERMES_SESSION_TOKEN__; - if (injected) { - _sessionToken = injected; - return _sessionToken; - } - throw new Error("Session token not available — page must be served by the Hermes dashboard server"); -} - /** * Fetch a single-use ticket for a WebSocket upgrade in gated mode. * @@ -553,17 +542,12 @@ export const api = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key }), }), - revealEnvVar: async (key: string) => { - const token = await getSessionToken(); - return fetchJSON<{ key: string; value: string }>("/api/env/reveal", { + revealEnvVar: (key: string) => + fetchJSON<{ key: string; value: string }>("/api/env/reveal", { method: "POST", - headers: { - "Content-Type": "application/json", - [SESSION_HEADER]: token, - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key }), - }); - }, + }), // Cron jobs getCronJobs: (profile = "all") => @@ -788,58 +772,42 @@ export const api = { // OAuth provider management getOAuthProviders: () => fetchJSON<OAuthProvidersResponse>("/api/providers/oauth"), - disconnectOAuthProvider: async (providerId: string) => { - const token = await getSessionToken(); - return fetchJSON<{ ok: boolean; provider: string }>( + disconnectOAuthProvider: (providerId: string) => + fetchJSON<{ ok: boolean; provider: string }>( `/api/providers/oauth/${encodeURIComponent(providerId)}`, { method: "DELETE", - headers: { [SESSION_HEADER]: token }, }, - ); - }, - startOAuthLogin: async (providerId: string) => { - const token = await getSessionToken(); - return fetchJSON<OAuthStartResponse>( + ), + startOAuthLogin: (providerId: string) => + fetchJSON<OAuthStartResponse>( `/api/providers/oauth/${encodeURIComponent(providerId)}/start`, { method: "POST", - headers: { - "Content-Type": "application/json", - [SESSION_HEADER]: token, - }, + headers: { "Content-Type": "application/json" }, body: "{}", }, - ); - }, - submitOAuthCode: async (providerId: string, sessionId: string, code: string) => { - const token = await getSessionToken(); - return fetchJSON<OAuthSubmitResponse>( + ), + submitOAuthCode: (providerId: string, sessionId: string, code: string) => + fetchJSON<OAuthSubmitResponse>( `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, { method: "POST", - headers: { - "Content-Type": "application/json", - [SESSION_HEADER]: token, - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: sessionId, code }), }, - ); - }, + ), pollOAuthSession: (providerId: string, sessionId: string) => fetchJSON<OAuthPollResponse>( `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`, ), - cancelOAuthSession: async (sessionId: string) => { - const token = await getSessionToken(); - return fetchJSON<{ ok: boolean }>( + cancelOAuthSession: (sessionId: string) => + fetchJSON<{ ok: boolean }>( `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE", - headers: { [SESSION_HEADER]: token }, }, - ); - }, + ), // Messaging platforms (gateway channels) getMessagingPlatforms: () => diff --git a/web/src/lib/clipboard.test.ts b/web/src/lib/clipboard.test.ts new file mode 100644 index 000000000000..8f6fd1fba211 --- /dev/null +++ b/web/src/lib/clipboard.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { copyTextToClipboard } from "./clipboard"; + +const originalNavigator = globalThis.navigator; +const originalDocument = globalThis.document; +const originalWindow = globalThis.window; + +function setGlobal<K extends keyof typeof globalThis>( + key: K, + value: (typeof globalThis)[K] | undefined, +) { + Object.defineProperty(globalThis, key, { + configurable: true, + value, + }); +} + +afterEach(() => { + setGlobal("navigator", originalNavigator); + setGlobal("document", originalDocument); + setGlobal("window", originalWindow); + vi.restoreAllMocks(); +}); + +describe("copyTextToClipboard", () => { + it("uses navigator.clipboard when available", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + setGlobal( + "navigator", + { clipboard: { writeText } } as unknown as Navigator, + ); + setGlobal("document", undefined); + + await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true); + expect(writeText).toHaveBeenCalledWith("CODEX-1234"); + }); + + it("falls back to selection copy when Clipboard API fails", async () => { + const writeText = vi.fn().mockRejectedValue(new Error("not allowed")); + const appendChild = vi.fn(); + const removeChild = vi.fn(); + const execCommand = vi.fn().mockReturnValue(true); + const textarea = { + focus: vi.fn(), + select: vi.fn(), + setAttribute: vi.fn(), + setSelectionRange: vi.fn(), + style: {}, + value: "", + } as unknown as HTMLTextAreaElement; + + setGlobal( + "navigator", + { clipboard: { writeText } } as unknown as Navigator, + ); + setGlobal("document", { + body: { appendChild, removeChild }, + createElement: vi.fn(() => textarea), + execCommand, + getSelection: vi.fn(() => null), + } as unknown as Document); + + await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true); + + expect(writeText).toHaveBeenCalledWith("CODEX-1234"); + expect(textarea.value).toBe("CODEX-1234"); + expect(appendChild).toHaveBeenCalledWith(textarea); + expect(textarea.select).toHaveBeenCalled(); + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(removeChild).toHaveBeenCalledWith(textarea); + }); + + it("uses selection copy directly in insecure browser contexts", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const appendChild = vi.fn(); + const removeChild = vi.fn(); + const execCommand = vi.fn().mockReturnValue(true); + const textarea = { + focus: vi.fn(), + select: vi.fn(), + setAttribute: vi.fn(), + setSelectionRange: vi.fn(), + style: {}, + value: "", + } as unknown as HTMLTextAreaElement; + + setGlobal( + "navigator", + { clipboard: { writeText } } as unknown as Navigator, + ); + setGlobal( + "window", + { isSecureContext: false } as unknown as Window & typeof globalThis, + ); + setGlobal("document", { + body: { appendChild, removeChild }, + createElement: vi.fn(() => textarea), + execCommand, + getSelection: vi.fn(() => null), + } as unknown as Document); + + await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true); + + expect(writeText).not.toHaveBeenCalled(); + expect(execCommand).toHaveBeenCalledWith("copy"); + }); + + it("returns false when no copy mechanism is available", async () => { + setGlobal("navigator", {} as Navigator); + setGlobal("document", undefined); + + await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(false); + }); +}); diff --git a/web/src/lib/clipboard.ts b/web/src/lib/clipboard.ts new file mode 100644 index 000000000000..749168e8e5da --- /dev/null +++ b/web/src/lib/clipboard.ts @@ -0,0 +1,56 @@ +export async function copyTextToClipboard(text: string): Promise<boolean> { + const clipboard = + typeof navigator === "undefined" ? undefined : navigator.clipboard; + const secureContext = + typeof window === "undefined" ? true : window.isSecureContext; + if (secureContext && clipboard?.writeText) { + try { + await clipboard.writeText(text); + return true; + } catch { + // Fall through to the selection-based copy path below. + } + } + + if (typeof document === "undefined") { + return false; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "-1000px"; + textarea.style.left = "-1000px"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + + const selection = document.getSelection(); + const ranges: Range[] = []; + if (selection) { + for (let i = 0; i < selection.rangeCount; i += 1) { + ranges.push(selection.getRangeAt(i)); + } + } + + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + let copied = false; + try { + copied = document.execCommand("copy"); + } catch { + copied = false; + } finally { + document.body.removeChild(textarea); + if (selection) { + selection.removeAllRanges(); + for (const range of ranges) { + selection.addRange(range); + } + } + } + + return copied; +} From ad8f1030489afbd6fad7fd9b03e820aad437c13e Mon Sep 17 00:00:00 2001 From: Shannon Sands <shannon.sands.1979@gmail.com> Date: Thu, 9 Jul 2026 14:39:03 +1000 Subject: [PATCH 275/610] i18n(dashboard): translate OAuth copy-code strings in all locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new oauth.copyCode/copyFailed keys existed only in en.ts, with optional types and English literal fallbacks in OAuthLoginModal — so non-English users got English strings on the device-code copy button. Backfill translations in all 16 non-English locales, refresh the updated oauth.description/notConnected copy (dashboard Login flow mention) to match en.ts, make the two keys required in the Translations interface, and drop the English fallbacks from the modal. Verified with web tsc --noEmit (required keys enforce locale completeness), vitest, and a web build. --- web/src/components/OAuthLoginModal.tsx | 9 +++------ web/src/i18n/af.ts | 7 +++++-- web/src/i18n/de.ts | 7 +++++-- web/src/i18n/es.ts | 7 +++++-- web/src/i18n/fr.ts | 7 +++++-- web/src/i18n/ga.ts | 7 +++++-- web/src/i18n/hu.ts | 7 +++++-- web/src/i18n/it.ts | 7 +++++-- web/src/i18n/ja.ts | 7 +++++-- web/src/i18n/ko.ts | 7 +++++-- web/src/i18n/pt.ts | 7 +++++-- web/src/i18n/ru.ts | 7 +++++-- web/src/i18n/tr.ts | 7 +++++-- web/src/i18n/types.ts | 4 ++-- web/src/i18n/uk.ts | 7 +++++-- web/src/i18n/zh-hant.ts | 7 +++++-- web/src/i18n/zh.ts | 7 +++++-- 17 files changed, 80 insertions(+), 38 deletions(-) diff --git a/web/src/components/OAuthLoginModal.tsx b/web/src/components/OAuthLoginModal.tsx index 2d1b07ac0295..dd815c2ee073 100644 --- a/web/src/components/OAuthLoginModal.tsx +++ b/web/src/components/OAuthLoginModal.tsx @@ -300,17 +300,14 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) { <Copy className="h-4 w-4" /> ) } - aria-label={t.oauth.copyCode ?? "Copy code"} + aria-label={t.oauth.copyCode} > - {copyStatus === "copied" - ? t.oauth.copied - : (t.oauth.copyCode ?? "Copy code")} + {copyStatus === "copied" ? t.oauth.copied : t.oauth.copyCode} </Button> </div> {copyStatus === "failed" && ( <p className="text-xs text-destructive"> - {t.oauth.copyFailed ?? - "Could not copy automatically. Select the code and copy it manually."} + {t.oauth.copyFailed} </p> )} <a diff --git a/web/src/i18n/af.ts b/web/src/i18n/af.ts index 63d7342c7074..8a7c8a1b96e6 100644 --- a/web/src/i18n/af.ts +++ b/web/src/i18n/af.ts @@ -446,16 +446,19 @@ export const af: Translations = { oauth: { title: "Verskaffer-aanmeldings (OAuth)", providerLogins: "Verskaffer-aanmeldings (OAuth)", - description: "{connected} van {total} OAuth-verskaffers gekoppel. Aanmeldvloei loop tans via die CLI; klik Kopieer opdrag en plak in 'n terminaal om op te stel.", + description: + "{connected} van {total} OAuth-verskaffers gekoppel. Gebruik Meld aan vir vloeie wat die kontroleskerm ondersteun; CLI-opdragte bly beskikbaar vir eksterne of terugval-opstelling.", connected: "Gekoppel", expired: "Verval", - notConnected: "Nie gekoppel nie. Voer {command} uit in 'n terminaal.", + notConnected: "Nie gekoppel nie. Gebruik Meld aan indien beskikbaar, of voer {command} uit in 'n terminaal.", runInTerminal: "in 'n terminaal.", noProviders: "Geen OAuth-bekwame verskaffers opgespoor nie.", login: "Meld aan", disconnect: "Ontkoppel", managedExternally: "Ekstern bestuur", copied: "Gekopieer ✓", + copyCode: "Kopieer kode", + copyFailed: "Kon nie outomaties kopieer nie. Kies die kode en kopieer dit met die hand.", cli: "Kopieer", copyCliCommand: "Kopieer CLI-opdrag (vir ekstern / terugval)", connect: "Koppel", diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index 4f316710406e..4a5bcb234611 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -446,16 +446,19 @@ export const de: Translations = { oauth: { title: "Anbieter-Logins (OAuth)", providerLogins: "Anbieter-Logins (OAuth)", - description: "{connected} von {total} OAuth-Anbietern verbunden. Login-Abläufe laufen derzeit über die CLI; klicke auf Befehl kopieren und füge ihn in ein Terminal ein, um einzurichten.", + description: + "{connected} von {total} OAuth-Anbietern verbunden. Nutze Anmelden für vom Dashboard unterstützte Abläufe; CLI-Befehle bleiben für externe oder Fallback-Einrichtung verfügbar.", connected: "Verbunden", expired: "Abgelaufen", - notConnected: "Nicht verbunden. Führe {command} in einem Terminal aus.", + notConnected: "Nicht verbunden. Nutze Anmelden, falls verfügbar, oder führe {command} in einem Terminal aus.", runInTerminal: "in einem Terminal.", noProviders: "Keine OAuth-fähigen Anbieter erkannt.", login: "Anmelden", disconnect: "Trennen", managedExternally: "Extern verwaltet", copied: "Kopiert ✓", + copyCode: "Code kopieren", + copyFailed: "Automatisches Kopieren fehlgeschlagen. Markiere den Code und kopiere ihn manuell.", cli: "Kopieren", copyCliCommand: "CLI-Befehl kopieren (für extern / Fallback)", connect: "Verbinden", diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index 2cf46f3007c0..fd209b5c1d65 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -447,16 +447,19 @@ export const es: Translations = { oauth: { title: "Inicios de sesión de proveedores (OAuth)", providerLogins: "Inicios de sesión de proveedores (OAuth)", - description: "{connected} de {total} proveedores OAuth conectados. Los flujos de inicio de sesión actualmente se ejecutan a través de la CLI; haz clic en Copiar comando y pégalo en una terminal para configurar.", + description: + "{connected} de {total} proveedores OAuth conectados. Usa Iniciar sesión para los flujos compatibles con el panel; los comandos CLI siguen disponibles para configuración externa o de respaldo.", connected: "Conectado", expired: "Caducado", - notConnected: "No conectado. Ejecuta {command} en una terminal.", + notConnected: "No conectado. Usa Iniciar sesión si está disponible, o ejecuta {command} en una terminal.", runInTerminal: "en una terminal.", noProviders: "No se han detectado proveedores compatibles con OAuth.", login: "Iniciar sesión", disconnect: "Desconectar", managedExternally: "Gestionado externamente", copied: "Copiado ✓", + copyCode: "Copiar código", + copyFailed: "No se pudo copiar automáticamente. Selecciona el código y cópialo manualmente.", cli: "Copiar", copyCliCommand: "Copiar comando CLI (para externo / alternativa)", connect: "Conectar", diff --git a/web/src/i18n/fr.ts b/web/src/i18n/fr.ts index 7c321b0d196c..f822232cc693 100644 --- a/web/src/i18n/fr.ts +++ b/web/src/i18n/fr.ts @@ -447,16 +447,19 @@ export const fr: Translations = { oauth: { title: "Connexions fournisseurs (OAuth)", providerLogins: "Connexions fournisseurs (OAuth)", - description: "{connected} sur {total} fournisseurs OAuth connectés. Les flux de connexion s'exécutent actuellement via le CLI ; cliquez sur Copier la commande et collez-la dans un terminal pour configurer.", + description: + "{connected} sur {total} fournisseurs OAuth connectés. Utilisez Connexion pour les flux pris en charge par le tableau de bord ; les commandes CLI restent disponibles pour une configuration externe ou de secours.", connected: "Connecté", expired: "Expiré", - notConnected: "Non connecté. Exécutez {command} dans un terminal.", + notConnected: "Non connecté. Utilisez Connexion si disponible, ou exécutez {command} dans un terminal.", runInTerminal: "dans un terminal.", noProviders: "Aucun fournisseur compatible OAuth détecté.", login: "Connexion", disconnect: "Déconnecter", managedExternally: "Géré en externe", copied: "Copié ✓", + copyCode: "Copier le code", + copyFailed: "Copie automatique impossible. Sélectionnez le code et copiez-le manuellement.", cli: "Copier", copyCliCommand: "Copier la commande CLI (pour externe / repli)", connect: "Connecter", diff --git a/web/src/i18n/ga.ts b/web/src/i18n/ga.ts index 2bd97ed5a586..7f7f82ef1911 100644 --- a/web/src/i18n/ga.ts +++ b/web/src/i18n/ga.ts @@ -454,16 +454,19 @@ export const ga: Translations = { oauth: { title: "Logálacha isteach soláthraí (OAuth)", providerLogins: "Logálacha isteach soláthraí (OAuth)", - description: "{connected} as {total} soláthraí OAuth ceangailte. Reáchtáiltear sreabha logála isteach faoi láthair tríd an CLI; cliceáil Cóipeáil ordú agus greamaigh i dteirminéal chun é a shocrú.", + description: + "{connected} as {total} soláthraí OAuth ceangailte. Úsáid Logáil isteach le haghaidh sreabha a dtacaíonn an deais leo; tá orduithe CLI ar fáil i gcónaí do shocrú seachtrach nó cúltaca.", connected: "Ceangailte", expired: "As feidhm", - notConnected: "Gan cheangal. Rith {command} i dteirminéal.", + notConnected: "Gan cheangal. Úsáid Logáil isteach má tá sé ar fáil, nó rith {command} i dteirminéal.", runInTerminal: "i dteirminéal.", noProviders: "Níor aimsíodh soláthraithe a thacaíonn le OAuth.", login: "Logáil isteach", disconnect: "Dícheangail", managedExternally: "Bainistithe go seachtrach", copied: "Cóipeáilte ✓", + copyCode: "Cóipeáil an cód", + copyFailed: "Níorbh fhéidir cóipeáil go huathoibríoch. Roghnaigh an cód agus cóipeáil de láimh é.", cli: "Cóipeáil", copyCliCommand: "Cóipeáil ordú CLI (le haghaidh úsáide seachtraí / cúltaca)", connect: "Ceangail", diff --git a/web/src/i18n/hu.ts b/web/src/i18n/hu.ts index f09e3264ea9a..beab21dcc8da 100644 --- a/web/src/i18n/hu.ts +++ b/web/src/i18n/hu.ts @@ -446,16 +446,19 @@ export const hu: Translations = { oauth: { title: "Szolgáltatói bejelentkezések (OAuth)", providerLogins: "Szolgáltatói bejelentkezések (OAuth)", - description: "{connected} / {total} OAuth-szolgáltató csatlakoztatva. A bejelentkezési folyamat jelenleg a CLI-n keresztül fut; kattintson a Parancs másolása gombra, és illessze be egy terminálba a beállításhoz.", + description: + "{connected} / {total} OAuth-szolgáltató csatlakoztatva. Használja a Bejelentkezés gombot az irányítópult által támogatott folyamatokhoz; a CLI-parancsok továbbra is elérhetők külső vagy tartalék beállításhoz.", connected: "Csatlakoztatva", expired: "Lejárt", - notConnected: "Nincs csatlakoztatva. Futtassa a {command} parancsot egy terminálban.", + notConnected: "Nincs csatlakoztatva. Használja a Bejelentkezés gombot, ha elérhető, vagy futtassa a {command} parancsot egy terminálban.", runInTerminal: "egy terminálban.", noProviders: "Nem észlelhető OAuth-képes szolgáltató.", login: "Bejelentkezés", disconnect: "Lecsatlakozás", managedExternally: "Külsőleg kezelt", copied: "Másolva ✓", + copyCode: "Kód másolása", + copyFailed: "Nem sikerült automatikusan másolni. Jelölje ki a kódot, és másolja kézzel.", cli: "Másolás", copyCliCommand: "CLI-parancs másolása (külső / tartalék)", connect: "Csatlakozás", diff --git a/web/src/i18n/it.ts b/web/src/i18n/it.ts index 927efa256c1d..beb22f5f0dfd 100644 --- a/web/src/i18n/it.ts +++ b/web/src/i18n/it.ts @@ -446,16 +446,19 @@ export const it: Translations = { oauth: { title: "Accessi provider (OAuth)", providerLogins: "Accessi provider (OAuth)", - description: "{connected} di {total} provider OAuth connessi. I flussi di accesso vengono attualmente eseguiti tramite la CLI; clicca Copia comando e incolla in un terminale per configurare.", + description: + "{connected} di {total} provider OAuth connessi. Usa Accedi per i flussi supportati dalla dashboard; i comandi CLI restano disponibili per configurazioni esterne o di riserva.", connected: "Connesso", expired: "Scaduto", - notConnected: "Non connesso. Esegui {command} in un terminale.", + notConnected: "Non connesso. Usa Accedi se disponibile, oppure esegui {command} in un terminale.", runInTerminal: "in un terminale.", noProviders: "Nessun provider compatibile con OAuth rilevato.", login: "Accedi", disconnect: "Disconnetti", managedExternally: "Gestito esternamente", copied: "Copiato ✓", + copyCode: "Copia codice", + copyFailed: "Impossibile copiare automaticamente. Seleziona il codice e copialo manualmente.", cli: "Copia", copyCliCommand: "Copia comando CLI (per uso esterno / fallback)", connect: "Connetti", diff --git a/web/src/i18n/ja.ts b/web/src/i18n/ja.ts index 4de06a02ece3..b3bb658a3298 100644 --- a/web/src/i18n/ja.ts +++ b/web/src/i18n/ja.ts @@ -445,16 +445,19 @@ export const ja: Translations = { oauth: { title: "プロバイダーログイン (OAuth)", providerLogins: "プロバイダーログイン (OAuth)", - description: "{connected} / {total} OAuth プロバイダーが接続されています。ログインフローは現在 CLI 経由で実行されます。「コマンドをコピー」をクリックして、ターミナルに貼り付けてセットアップしてください。", + description: + "{connected} / {total} OAuth プロバイダーが接続されています。ダッシュボード対応のフローには「ログイン」を使用してください。外部またはフォールバック用のセットアップには引き続き CLI コマンドを利用できます。", connected: "接続済み", expired: "期限切れ", - notConnected: "未接続です。ターミナルで {command} を実行してください。", + notConnected: "未接続です。可能な場合は「ログイン」を使用するか、ターミナルで {command} を実行してください。", runInTerminal: "ターミナルで実行してください。", noProviders: "OAuth 対応プロバイダーは検出されませんでした。", login: "ログイン", disconnect: "切断", managedExternally: "外部で管理", copied: "コピーしました ✓", + copyCode: "コードをコピー", + copyFailed: "自動でコピーできませんでした。コードを選択して手動でコピーしてください。", cli: "コピー", copyCliCommand: "CLI コマンドをコピー (外部 / フォールバック用)", connect: "接続", diff --git a/web/src/i18n/ko.ts b/web/src/i18n/ko.ts index 6cd65f3133fe..e4dc604c4cfb 100644 --- a/web/src/i18n/ko.ts +++ b/web/src/i18n/ko.ts @@ -445,16 +445,19 @@ export const ko: Translations = { oauth: { title: "제공자 로그인 (OAuth)", providerLogins: "제공자 로그인 (OAuth)", - description: "{connected}/{total} OAuth 제공자가 연결되었습니다. 로그인 흐름은 현재 CLI를 통해 실행됩니다. 명령 복사를 클릭하고 터미널에 붙여넣어 설정하세요.", + description: + "{connected}/{total} OAuth 제공자가 연결되었습니다. 대시보드에서 지원되는 흐름에는 로그인을 사용하세요. 외부 또는 대체 설정에는 CLI 명령을 계속 사용할 수 있습니다.", connected: "연결됨", expired: "만료됨", - notConnected: "연결되지 않음. 터미널에서 {command}을(를) 실행하세요.", + notConnected: "연결되지 않음. 가능하면 로그인을 사용하거나 터미널에서 {command}을(를) 실행하세요.", runInTerminal: "터미널에서.", noProviders: "OAuth를 지원하는 제공자가 감지되지 않았습니다.", login: "로그인", disconnect: "연결 해제", managedExternally: "외부에서 관리됨", copied: "복사됨 ✓", + copyCode: "코드 복사", + copyFailed: "자동으로 복사할 수 없습니다. 코드를 선택하여 직접 복사하세요.", cli: "복사", copyCliCommand: "CLI 명령 복사 (외부 / 대체용)", connect: "연결", diff --git a/web/src/i18n/pt.ts b/web/src/i18n/pt.ts index 90b5ea42355e..7e2513f4a9e1 100644 --- a/web/src/i18n/pt.ts +++ b/web/src/i18n/pt.ts @@ -447,16 +447,19 @@ export const pt: Translations = { oauth: { title: "Inícios de sessão de fornecedor (OAuth)", providerLogins: "Inícios de sessão de fornecedor (OAuth)", - description: "{connected} de {total} fornecedores OAuth ligados. Os fluxos de início de sessão são executados via CLI; clique em Copiar comando e cole num terminal para configurar.", + description: + "{connected} de {total} fornecedores OAuth ligados. Use Iniciar sessão para fluxos suportados pelo painel; os comandos CLI continuam disponíveis para configuração externa ou de recurso.", connected: "Ligado", expired: "Expirado", - notConnected: "Não ligado. Execute {command} num terminal.", + notConnected: "Não ligado. Use Iniciar sessão quando disponível, ou execute {command} num terminal.", runInTerminal: "num terminal.", noProviders: "Não foram detetados fornecedores compatíveis com OAuth.", login: "Iniciar sessão", disconnect: "Desligar", managedExternally: "Gerido externamente", copied: "Copiado ✓", + copyCode: "Copiar código", + copyFailed: "Não foi possível copiar automaticamente. Selecione o código e copie-o manualmente.", cli: "Copiar", copyCliCommand: "Copiar comando CLI (para externo / fallback)", connect: "Ligar", diff --git a/web/src/i18n/ru.ts b/web/src/i18n/ru.ts index c133f0398e98..c8db540f11df 100644 --- a/web/src/i18n/ru.ts +++ b/web/src/i18n/ru.ts @@ -446,16 +446,19 @@ export const ru: Translations = { oauth: { title: "Входы провайдеров (OAuth)", providerLogins: "Входы провайдеров (OAuth)", - description: "Подключено {connected} из {total} OAuth-провайдеров. Процесс входа в настоящее время выполняется через CLI; нажмите «Скопировать команду» и вставьте в терминал для настройки.", + description: + "Подключено {connected} из {total} OAuth-провайдеров. Используйте «Войти» для процессов, поддерживаемых панелью; команды CLI остаются доступными для внешней или резервной настройки.", connected: "Подключено", expired: "Срок истёк", - notConnected: "Не подключено. Выполните {command} в терминале.", + notConnected: "Не подключено. Используйте «Войти», если доступно, или выполните {command} в терминале.", runInTerminal: "в терминале.", noProviders: "OAuth-совместимые провайдеры не обнаружены.", login: "Войти", disconnect: "Отключить", managedExternally: "Управляется извне", copied: "Скопировано ✓", + copyCode: "Скопировать код", + copyFailed: "Не удалось скопировать автоматически. Выделите код и скопируйте его вручную.", cli: "Копировать", copyCliCommand: "Скопировать CLI-команду (для внешнего / резервного варианта)", connect: "Подключить", diff --git a/web/src/i18n/tr.ts b/web/src/i18n/tr.ts index e23dad98d8f9..dc9a62894b1b 100644 --- a/web/src/i18n/tr.ts +++ b/web/src/i18n/tr.ts @@ -446,16 +446,19 @@ export const tr: Translations = { oauth: { title: "Sağlayıcı Girişleri (OAuth)", providerLogins: "Sağlayıcı Girişleri (OAuth)", - description: "{connected}/{total} OAuth sağlayıcısı bağlandı. Giriş akışları şu anda CLI üzerinden çalışır; Komutu kopyala'ya tıklayın ve kurmak için bir terminale yapıştırın.", + description: + "{connected}/{total} OAuth sağlayıcısı bağlandı. Panel destekli akışlar için Giriş'i kullanın; CLI komutları harici veya yedek kurulum için kullanılabilir.", connected: "Bağlandı", expired: "Süresi doldu", - notConnected: "Bağlı değil. Bir terminalde {command} komutunu çalıştırın.", + notConnected: "Bağlı değil. Mümkünse Giriş'i kullanın veya bir terminalde {command} komutunu çalıştırın.", runInTerminal: "bir terminalde.", noProviders: "OAuth uyumlu sağlayıcı algılanmadı.", login: "Giriş", disconnect: "Bağlantıyı kes", managedExternally: "Harici olarak yönetiliyor", copied: "Kopyalandı ✓", + copyCode: "Kodu kopyala", + copyFailed: "Otomatik olarak kopyalanamadı. Kodu seçip elle kopyalayın.", cli: "Kopyala", copyCliCommand: "CLI komutunu kopyala (harici / yedek için)", connect: "Bağlan", diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 9898891dc04a..6a888226adbf 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -530,8 +530,8 @@ export interface Translations { disconnect: string; managedExternally: string; copied: string; - copyCode?: string; - copyFailed?: string; + copyCode: string; + copyFailed: string; cli: string; copyCliCommand: string; connect: string; diff --git a/web/src/i18n/uk.ts b/web/src/i18n/uk.ts index 8c329b731c37..c8c5c28787ca 100644 --- a/web/src/i18n/uk.ts +++ b/web/src/i18n/uk.ts @@ -447,16 +447,19 @@ export const uk: Translations = { oauth: { title: "Входи постачальників (OAuth)", providerLogins: "Входи постачальників (OAuth)", - description: "Підключено {connected} з {total} постачальників OAuth. Процеси входу наразі виконуються через CLI; натисніть «Скопіювати команду» та вставте у термінал, щоб налаштувати.", + description: + "Підключено {connected} з {total} постачальників OAuth. Використовуйте «Увійти» для процесів, підтримуваних панеллю; команди CLI залишаються доступними для зовнішнього або резервного налаштування.", connected: "Підключено", expired: "Прострочено", - notConnected: "Не підключено. Виконайте {command} у терміналі.", + notConnected: "Не підключено. Використовуйте «Увійти», якщо доступно, або виконайте {command} у терміналі.", runInTerminal: "у терміналі.", noProviders: "Не виявлено постачальників із підтримкою OAuth.", login: "Увійти", disconnect: "Відключити", managedExternally: "Керується ззовні", copied: "Скопійовано ✓", + copyCode: "Скопіювати код", + copyFailed: "Не вдалося скопіювати автоматично. Виділіть код і скопіюйте його вручну.", cli: "Копіювати", copyCliCommand: "Скопіювати CLI-команду (для зовнішнього / резервного варіанту)", connect: "Підключити", diff --git a/web/src/i18n/zh-hant.ts b/web/src/i18n/zh-hant.ts index c4ec4af3e77a..eeb72988ca12 100644 --- a/web/src/i18n/zh-hant.ts +++ b/web/src/i18n/zh-hant.ts @@ -445,16 +445,19 @@ export const zhHant: Translations = { oauth: { title: "提供者登入(OAuth)", providerLogins: "提供者登入(OAuth)", - description: "已連線 {connected}/{total} 個 OAuth 提供者。登入流程目前透過 CLI 執行;請點擊「複製指令」並貼到終端機完成設定。", + description: + "已連線 {connected}/{total} 個 OAuth 提供者。儀表板支援的流程請使用「登入」;CLI 指令仍可用於外部或備用設定。", connected: "已連線", expired: "已過期", - notConnected: "未連線。請在終端機執行 {command}。", + notConnected: "未連線。可用時請使用「登入」,或在終端機執行 {command}。", runInTerminal: "於終端機。", noProviders: "未偵測到支援 OAuth 的提供者。", login: "登入", disconnect: "中斷連線", managedExternally: "由外部管理", copied: "已複製 ✓", + copyCode: "複製代碼", + copyFailed: "無法自動複製。請選取代碼並手動複製。", cli: "複製", copyCliCommand: "複製 CLI 指令(外部 / 備援用)", connect: "連線", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index b34f3341a42b..4cf821c85aa1 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -440,16 +440,19 @@ export const zh: Translations = { oauth: { title: "提供商登录(OAuth)", providerLogins: "提供商登录(OAuth)", - description: "已连接 {connected}/{total} 个 OAuth 提供商。登录流程目前通过 CLI 运行;点击「复制命令」并粘贴到终端中进行设置。", + description: + "已连接 {connected}/{total} 个 OAuth 提供商。仪表板支持的流程请使用「登录」;CLI 命令仍可用于外部或备用设置。", connected: "已连接", expired: "已过期", - notConnected: "未连接。在终端中运行 {command}。", + notConnected: "未连接。可用时请使用「登录」,或在终端中运行 {command}。", runInTerminal: "在终端中。", noProviders: "未检测到支持 OAuth 的提供商。", login: "登录", disconnect: "断开连接", managedExternally: "外部管理", copied: "已复制 ✓", + copyCode: "复制代码", + copyFailed: "无法自动复制。请选中代码并手动复制。", cli: "复制", copyCliCommand: "复制 CLI 命令(用于外部/备用方式)", connect: "连接", From 473407174bf304a083df166e40173503ea9eee89 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:13:44 +0530 Subject: [PATCH 276/610] test(dashboard): assert server still gates OAuth endpoints without cookie PR #61281 removed the client-side X-Hermes-Session-Token requirement from the dashboard OAuth mutation calls so cookie-authenticated hosted/mobile sessions can start provider logins. That change is safe only because the server still gates those endpoints (gated_auth_middleware cookie check + _require_token). The PR's api.test.ts suite mocks fetch and only asserts client behavior, so a re-break of the gated-mode cookie gate would pass CI unnoticed. Add gated-mode TestClient tests asserting POST /api/env/reveal and the OAuth mutation endpoints (disconnect/start/submit/cancel) return 401 with no session cookie. Mutation-verified: neutering both the middleware gate and _require_token flips all five to 200. --- ...t_dashboard_oauth_endpoints_server_gate.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/hermes_cli/test_dashboard_oauth_endpoints_server_gate.py diff --git a/tests/hermes_cli/test_dashboard_oauth_endpoints_server_gate.py b/tests/hermes_cli/test_dashboard_oauth_endpoints_server_gate.py new file mode 100644 index 000000000000..f2b85a3e2102 --- /dev/null +++ b/tests/hermes_cli/test_dashboard_oauth_endpoints_server_gate.py @@ -0,0 +1,69 @@ +"""Regression guard for PR #61281 (mobile/hosted dashboard OAuth). + +The PR removed the *client-side* ``X-Hermes-Session-Token`` requirement from +the dashboard OAuth mutation calls (``web/src/lib/api.ts``) so that +cookie-authenticated hosted/mobile sessions can start provider logins. The +safety of that change rests entirely on the *server* still gating those +endpoints: in gated mode the ``gated_auth_middleware`` verifies the session +cookie before the handler runs, and ``_require_token`` defers to it. + +These tests pin that server-side gate for the exact endpoints whose +client-side token gate was removed. Without them, a future change that +re-broke ``_require_token``'s gated-mode branch (e.g. letting it fall through +without a session) would still pass the PR's ``api.test.ts`` suite, because +those tests only mock ``fetch`` and never touch the server. +""" + +import pytest +from fastapi.testclient import TestClient + +from hermes_cli import web_server +from hermes_cli.dashboard_auth import clear_providers, register_provider +from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider + + +@pytest.fixture +def gated_app(): + """A gated (``auth_required``) dashboard with no session cookie set.""" + clear_providers() + register_provider(StubAuthProvider()) + prev_host = getattr(web_server.app.state, "bound_host", None) + prev_port = getattr(web_server.app.state, "bound_port", None) + prev_required = getattr(web_server.app.state, "auth_required", None) + web_server.app.state.bound_host = "fly-app.fly.dev" + web_server.app.state.bound_port = 443 + web_server.app.state.auth_required = True + client = TestClient(web_server.app, base_url="https://fly-app.fly.dev") + yield client + clear_providers() + web_server.app.state.bound_host = prev_host + web_server.app.state.bound_port = prev_port + web_server.app.state.auth_required = prev_required + + +class TestOAuthMutationEndpointsGatedWithoutCookie: + """No cookie in gated mode -> 401 on every endpoint whose client-side + session-token gate PR #61281 removed.""" + + def test_env_reveal_requires_cookie(self, gated_app): + r = gated_app.post("/api/env/reveal", json={"key": "OPENAI_API_KEY"}) + assert r.status_code == 401 + + def test_oauth_disconnect_requires_cookie(self, gated_app): + r = gated_app.delete("/api/providers/oauth/anthropic") + assert r.status_code == 401 + + def test_oauth_start_requires_cookie(self, gated_app): + r = gated_app.post("/api/providers/oauth/anthropic/start", json={}) + assert r.status_code == 401 + + def test_oauth_submit_requires_cookie(self, gated_app): + r = gated_app.post( + "/api/providers/oauth/anthropic/submit", + json={"session_id": "sid", "code": "abc"}, + ) + assert r.status_code == 401 + + def test_oauth_cancel_session_requires_cookie(self, gated_app): + r = gated_app.delete("/api/providers/oauth/sessions/sid") + assert r.status_code == 401 From 32a0f9e17a570c046f89887ac75e3b3b46490d29 Mon Sep 17 00:00:00 2001 From: infinitycrew39 <infinitycrew39@gmail.com> Date: Wed, 8 Jul 2026 23:53:58 +0700 Subject: [PATCH 277/610] fix(model_metadata): avoid str(tools) token estimate stalls Estimate tool-schema size without repeatedly stringifying full tool lists, and cache the result per tool snapshot to reduce GIL-heavy work during preflight and compaction. --- agent/model_metadata.py | 68 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 88fab0902513..3626e8db0c96 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -2439,5 +2439,71 @@ def estimate_request_tokens_rough( if messages: total += estimate_messages_tokens_rough(messages) if tools: - total += (len(str(tools)) + 3) // 4 + total += _estimate_tools_tokens_rough(tools) return total + + +# NOTE: tool schemas can be large. Avoid repeated `str(tools)` conversions, +# which are CPU-heavy and can stall GUI event loops under GIL pressure. +_TOOLS_TOKENS_CACHE: dict[int, Tuple[int, str, str, int]] = {} + + +def _tool_name_for_cache(tool: Any) -> str: + if not isinstance(tool, dict): + return "" + fn = tool.get("function") + if isinstance(fn, dict): + name = fn.get("name") + if isinstance(name, str): + return name + name = tool.get("name") + return name if isinstance(name, str) else "" + + +def _estimate_tools_tokens_rough(tools: List[Dict[str, Any]]) -> int: + if not tools: + return 0 + + # Cache by list identity. Tools are rebuilt rarely (toolset changes), + # but token estimates are requested frequently (preflight, compaction). + key = id(tools) + n = len(tools) + first = _tool_name_for_cache(tools[0]) if n else "" + last = _tool_name_for_cache(tools[-1]) if n else "" + + cached = _TOOLS_TOKENS_CACHE.get(key) + if cached is not None: + cached_n, cached_first, cached_last, cached_tokens = cached + if cached_n == n and cached_first == first and cached_last == last: + return cached_tokens + + # Fast, stable rough estimate: sum lengths of the major schema fields. + # This avoids the pathological `str(tools)` path while still scaling with + # schema size (descriptions + parameters dominate). + total_chars = 0 + for tool in tools: + if not isinstance(tool, dict): + continue + fn = tool.get("function") + if isinstance(fn, dict): + name = fn.get("name") or "" + desc = fn.get("description") or "" + params = fn.get("parameters") or {} + else: + name = tool.get("name") or "" + desc = tool.get("description") or "" + params = tool.get("parameters") or {} + + if isinstance(name, str): + total_chars += len(name) + if isinstance(desc, str): + total_chars += len(desc) + # Parameters can be nested; JSON is closer to over-the-wire size than repr(). + try: + total_chars += len(json.dumps(params, ensure_ascii=False, separators=(",", ":"))) + except Exception: + total_chars += len(str(params)) + + tokens = (total_chars + 3) // 4 + _TOOLS_TOKENS_CACHE[key] = (n, first, last, tokens) + return tokens From f4d5cfd0fdebfd6a8a242c102750a77a38a4d6c8 Mon Sep 17 00:00:00 2001 From: infinitycrew39 <infinitycrew39@gmail.com> Date: Wed, 8 Jul 2026 23:53:58 +0700 Subject: [PATCH 278/610] test(model_metadata): cache tools schema token estimate Adds a regression test that repeated request-token estimates do not re-serialize the same tool schema list. --- tests/agent/test_model_metadata.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index d450580cd3c6..47a955755558 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -30,6 +30,7 @@ from agent.model_metadata import ( save_context_length, fetch_model_metadata, _MODEL_CACHE_TTL, + estimate_request_tokens_rough, ) @@ -120,6 +121,28 @@ class TestEstimateMessagesTokensRough: assert result < 5000 +class TestEstimateRequestTokensRough: + def test_caches_tools_estimate(self): + messages = [{"role": "user", "content": "hello"}] + tools = [ + { + "type": "function", + "function": { + "name": "terminal", + "description": "Run a command", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}}, + }, + } + ] + + # json.dumps is used for params sizing; ensure the tools estimate is cached + # so repeated calls don't keep re-serializing the same schema list. + with patch("agent.model_metadata.json.dumps", wraps=__import__("json").dumps) as dumps: + estimate_request_tokens_rough(messages, system_prompt="x" * 8, tools=tools) + estimate_request_tokens_rough(messages, system_prompt="x" * 8, tools=tools) + assert dumps.call_count == 1 + + # ========================================================================= # Default context lengths # ========================================================================= From 55dbc3ffb5e42ab1ba76c8eb77ffec59a6e63e8f Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:21:42 +0530 Subject: [PATCH 279/610] fix(model_metadata): bound the tools-token estimate cache Follow-up to the salvaged str(tools) fix. The id()-keyed _TOOLS_TOKENS_CACHE had no eviction, so a long-lived gateway/desktop backend could accumulate an unbounded number of stale entries as it builds transient tool lists. Cap it at 256 with oldest-first eviction (insertion-ordered dict) and add a regression test asserting the cache never exceeds the cap. --- agent/model_metadata.py | 11 +++++++++++ tests/agent/test_model_metadata.py | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 3626e8db0c96..b60ab1eaedc4 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -2445,7 +2445,13 @@ def estimate_request_tokens_rough( # NOTE: tool schemas can be large. Avoid repeated `str(tools)` conversions, # which are CPU-heavy and can stall GUI event loops under GIL pressure. +# +# Keyed by ``id(tools)``. A long-lived gateway/desktop backend builds many +# transient tool lists over its lifetime, so the cache is bounded and evicts +# oldest-first (insertion-ordered dict) once it exceeds the cap. The cap is +# generous relative to how rarely toolsets are rebuilt within a process. _TOOLS_TOKENS_CACHE: dict[int, Tuple[int, str, str, int]] = {} +_TOOLS_TOKENS_CACHE_MAX = 256 def _tool_name_for_cache(tool: Any) -> str: @@ -2505,5 +2511,10 @@ def _estimate_tools_tokens_rough(tools: List[Dict[str, Any]]) -> int: total_chars += len(str(params)) tokens = (total_chars + 3) // 4 + # Bound the cache: drop the oldest entry when the cap is exceeded so a + # long-running process can't accumulate an unbounded number of stale + # ``id(tools)`` entries (id values are recycled after GC anyway). + if len(_TOOLS_TOKENS_CACHE) >= _TOOLS_TOKENS_CACHE_MAX: + _TOOLS_TOKENS_CACHE.pop(next(iter(_TOOLS_TOKENS_CACHE)), None) _TOOLS_TOKENS_CACHE[key] = (n, first, last, tokens) return tokens diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index 47a955755558..ecaf3909ea24 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -142,6 +142,32 @@ class TestEstimateRequestTokensRough: estimate_request_tokens_rough(messages, system_prompt="x" * 8, tools=tools) assert dumps.call_count == 1 + def test_tools_cache_is_bounded(self): + # A long-lived process builds many transient tool lists; the cache must + # not grow without bound. Feed more distinct lists than the cap and + # confirm the cache never exceeds it. + import agent.model_metadata as mm + + mm._TOOLS_TOKENS_CACHE.clear() + cap = mm._TOOLS_TOKENS_CACHE_MAX + # Keep references so ids are not recycled mid-loop, forcing distinct keys. + held = [] + for i in range(cap + 50): + tools = [ + { + "type": "function", + "function": { + "name": f"tool_{i}", + "description": "d", + "parameters": {"type": "object"}, + }, + } + ] + held.append(tools) + mm._estimate_tools_tokens_rough(tools) + assert len(mm._TOOLS_TOKENS_CACHE) <= cap + assert len(mm._TOOLS_TOKENS_CACHE) == cap + # ========================================================================= # Default context lengths From cbf685356d99f470adda32731e4c1942e088b0ae Mon Sep 17 00:00:00 2001 From: HexLab98 <liruixinch@outlook.com> Date: Wed, 8 Jul 2026 22:49:57 +0700 Subject: [PATCH 280/610] fix(gateway): sync HERMES_HOME before refreshing system systemd units Under sudo, start/restart refreshed the unit from /root/.hermes before adopting the unit's pinned home, so TimeoutStopSec and env drifted and status stayed stuck on "service definition is outdated". --- hermes_cli/gateway.py | 64 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index ab743b281f19..d40a9597028c 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -938,6 +938,32 @@ def _read_systemd_unit_environment(system: bool = False) -> dict[str, str]: return parsed +def _hermes_home_from_systemd_unit_file(system: bool = False) -> str | None: + """Read ``HERMES_HOME`` from the on-disk unit file (not ``systemctl show``). + + Prefer the file when refreshing/comparing: under ``sudo``, ``systemctl`` + may be slow/unavailable in tests, and the on-disk unit is what + ``systemd_unit_is_current`` / ``refresh_systemd_unit_if_needed`` already + compare against. + """ + unit_path = get_systemd_unit_path(system=system) + if not unit_path.exists(): + return None + try: + text = unit_path.read_text(encoding="utf-8") + except OSError: + return None + for line in text.splitlines(): + stripped = line.strip() + if not stripped.startswith("Environment="): + continue + body = stripped[len("Environment=") :].strip().strip('"') + if body.startswith("HERMES_HOME="): + value = body.split("=", 1)[1].strip().strip('"') + return value or None + return None + + def _sync_hermes_home_from_systemd_unit(system: bool) -> None: """When acting on a system-scope unit, adopt its ``HERMES_HOME``. @@ -949,8 +975,11 @@ def _sync_hermes_home_from_systemd_unit(system: bool) -> None: """ if not system: return - env = _read_systemd_unit_environment(system=True) - unit_home = env.get("HERMES_HOME", "").strip() + # Prefer the on-disk unit (source of truth for refresh/compare). Fall + # back to ``systemctl show`` for units that only exist in the manager. + unit_home = (_hermes_home_from_systemd_unit_file(system=True) or "").strip() + if not unit_home: + unit_home = _read_systemd_unit_environment(system=True).get("HERMES_HOME", "").strip() if not unit_home: return current = os.environ.get("HERMES_HOME", "").strip() @@ -2830,6 +2859,13 @@ def systemd_unit_is_current(system: bool = False) -> bool: if not unit_path.exists(): return False + # Under ``sudo hermes gateway … --system``, HERMES_HOME is often stripped + # and falls back to ``/root/.hermes``. Adopt the unit's pinned home first + # so TimeoutStopSec / WorkingDirectory / HERMES_HOME comparisons use the + # real operator config — otherwise start/restart "refresh" rewrites a + # correct unit from root's defaults and ``status`` keeps warning forever. + _sync_hermes_home_from_systemd_unit(system=system) + installed = unit_path.read_text(encoding="utf-8") expected_user = _read_systemd_user_from_unit(unit_path) if system else None expected = generate_systemd_unit(system=system, run_as_user=expected_user) @@ -2907,7 +2943,15 @@ def _refuse_temp_home_service_write(definition: str, kind: str) -> bool: def refresh_systemd_unit_if_needed(system: bool = False) -> bool: """Rewrite the installed systemd unit when the generated definition has changed.""" unit_path = get_systemd_unit_path(system=system) - if not unit_path.exists() or systemd_unit_is_current(system=system): + if not unit_path.exists(): + return False + + # Sync before the current-check / regenerate path so a ``sudo`` shell + # does not bake root's config into a system unit that already pins the + # operator's HERMES_HOME. ``systemd_unit_is_current`` also syncs; doing + # it here keeps the write path safe even if that helper changes. + _sync_hermes_home_from_systemd_unit(system=system) + if systemd_unit_is_current(system=system): return False expected_user = _read_systemd_user_from_unit(unit_path) if system else None @@ -3094,6 +3138,12 @@ def systemd_install( unit_path = get_systemd_unit_path(system=system) scope_flag = " --system" if system else "" + # Existing system units already pin HERMES_HOME; adopt it before any + # current-check / regenerate so ``sudo hermes gateway install --system`` + # cannot "repair" a correct unit from root's config. + if unit_path.exists(): + _sync_hermes_home_from_systemd_unit(system=system) + if unit_path.exists() and not force: if not systemd_unit_is_current(system=system): print( @@ -3184,6 +3234,9 @@ def systemd_start(system: bool = False): # Raises UserSystemdUnavailableError with a remediation message. _preflight_user_systemd() _require_service_installed("start", system=system) + # Adopt the unit's HERMES_HOME before refresh so sudo system starts do not + # rewrite TimeoutStopSec / env from /root/.hermes. + _sync_hermes_home_from_systemd_unit(system=system) refresh_systemd_unit_if_needed(system=system) _run_systemctl(["start", get_service_name()], system=system, check=True, timeout=30) print(f"✓ {_service_scope_label(system).capitalize()} service started") @@ -3224,8 +3277,11 @@ def systemd_restart(system: bool = False): else: _preflight_user_systemd() _require_service_installed("restart", system=system) - refresh_systemd_unit_if_needed(system=system) + # Sync BEFORE refresh. Under sudo, refreshing first used to regenerate the + # unit from /root/.hermes (wrong drain timeout / env), then sync for PID + # lookup — leaving ``status`` stuck on "service definition is outdated". _sync_hermes_home_from_systemd_unit(system=system) + refresh_systemd_unit_if_needed(system=system) from gateway.status import get_running_pid pid = get_running_pid() or _systemd_main_pid(system=system) From 8f18f6c6952371055ac78394a136e6cd911d3742 Mon Sep 17 00:00:00 2001 From: HexLab98 <liruixinch@outlook.com> Date: Wed, 8 Jul 2026 22:50:17 +0700 Subject: [PATCH 281/610] test(gateway): cover sudo system-unit refresh adopting HERMES_HOME --- tests/hermes_cli/test_gateway_service.py | 109 +++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index c2d03625dc36..c0298617aaf8 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -2029,6 +2029,115 @@ class TestSystemUnitHermesHome: assert f'HERMES_HOME={hermes_home}' in unit +class TestSystemUnitRefreshSyncsHermesHome: + """sudo system refresh must not flip TimeoutStopSec via /root/.hermes.""" + + def test_refresh_adopts_unit_hermes_home_before_rewriting(self, tmp_path, monkeypatch): + root_home = tmp_path / "root" + alice_home = tmp_path / "alice" + root_hermes = root_home / ".hermes" + alice_hermes = alice_home / ".hermes" + root_hermes.mkdir(parents=True) + alice_hermes.mkdir(parents=True) + (root_hermes / "config.yaml").write_text( + "agent:\n restart_drain_timeout: 60\n", encoding="utf-8" + ) + (alice_hermes / "config.yaml").write_text( + "agent:\n restart_drain_timeout: 180\n", encoding="utf-8" + ) + + unit_path = tmp_path / "hermes-gateway.service" + monkeypatch.setattr(Path, "home", staticmethod(lambda: root_home)) + monkeypatch.setattr( + gateway_cli, + "_system_service_identity", + lambda run_as_user=None: ("alice", "alice", str(alice_home)), + ) + monkeypatch.setattr( + gateway_cli, "_build_user_local_paths", lambda home, existing: [] + ) + monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: None) + monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) + monkeypatch.setattr(gateway_cli, "_run_systemctl", lambda *a, **k: None) + monkeypatch.delenv("HERMES_RESTART_DRAIN_TIMEOUT", raising=False) + + # Correct installed unit (operator's HERMES_HOME + drain timeout). + monkeypatch.setenv("HERMES_HOME", str(alice_hermes)) + good_unit = gateway_cli.generate_systemd_unit(system=True, run_as_user="alice") + assert "TimeoutStopSec=210" in good_unit + unit_path.write_text(good_unit, encoding="utf-8") + + # Simulate sudo without inherited HERMES_HOME (falls back to root). + monkeypatch.setenv("HERMES_HOME", str(root_hermes)) + assert gateway_cli.refresh_systemd_unit_if_needed(system=True) is False + assert unit_path.read_text(encoding="utf-8") == good_unit + assert os.environ["HERMES_HOME"] == str(alice_hermes) + assert gateway_cli.systemd_unit_is_current(system=True) is True + + def test_systemd_restart_syncs_before_refresh(self, monkeypatch): + calls = [] + + monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: True) + monkeypatch.setattr(gateway_cli, "_require_root_for_system_service", lambda action: None) + monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) + monkeypatch.setattr( + gateway_cli, + "_sync_hermes_home_from_systemd_unit", + lambda system: calls.append(("sync", system)), + ) + monkeypatch.setattr( + gateway_cli, + "refresh_systemd_unit_if_needed", + lambda system=False: calls.append(("refresh", system)), + ) + monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) + monkeypatch.setattr(gateway_cli, "_systemd_main_pid", lambda system=False: None) + monkeypatch.setattr( + gateway_cli, + "_run_systemctl", + lambda args, **kwargs: calls.append(("systemctl", args)) + or SimpleNamespace(returncode=0, stdout="", stderr=""), + ) + monkeypatch.setattr( + gateway_cli, + "_wait_for_systemd_service_restart", + lambda system=False, previous_pid=None: True, + ) + + gateway_cli.systemd_restart(system=True) + + assert calls[0] == ("sync", True) + assert calls[1] == ("refresh", True) + + def test_systemd_start_syncs_before_refresh(self, monkeypatch): + calls = [] + + monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: True) + monkeypatch.setattr(gateway_cli, "_require_root_for_system_service", lambda action: None) + monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) + monkeypatch.setattr( + gateway_cli, + "_sync_hermes_home_from_systemd_unit", + lambda system: calls.append(("sync", system)), + ) + monkeypatch.setattr( + gateway_cli, + "refresh_systemd_unit_if_needed", + lambda system=False: calls.append(("refresh", system)), + ) + monkeypatch.setattr( + gateway_cli, + "_run_systemctl", + lambda args, **kwargs: calls.append(("systemctl", args)) + or SimpleNamespace(returncode=0, stdout="", stderr=""), + ) + + gateway_cli.systemd_start(system=True) + + assert calls[0] == ("sync", True) + assert calls[1] == ("refresh", True) + + class TestHermesHomeForTargetUser: """Unit tests for _hermes_home_for_target_user().""" From d54a8f7079e42505eaa0dfbecf651f50bfd6d574 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:57:37 +0530 Subject: [PATCH 282/610] refactor(gateway): funnel HERMES_HOME sync through a single chokepoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to HexLab98's fix. The sync-before-regenerate invariant was enforced by convention across 6 callsites (~3 idempotent unit-file reads per command). Consolidate it into the one function every compare/regenerate path funnels through — systemd_unit_is_current — and drop the now-redundant callsite pre-syncs in refresh_systemd_unit_if_needed / systemd_start / systemd_restart / systemd_status. Kept the systemd_install pre-sync: the --force path bypasses the is_current gate and calls generate_systemd_unit() directly, so it needs its own sync to avoid baking /root/.hermes under sudo. Reworked the two callsite-ordering tests into a chokepoint-invariant guard (test_is_current_syncs_before_reading_unit) + a delegation test proving start/restart no longer pre-sync. Both fail if the chokepoint sync is removed; the pre-existing behavior test still passes. --- hermes_cli/gateway.py | 58 ++++++---- tests/hermes_cli/test_gateway_service.py | 132 +++++++++++++---------- 2 files changed, 112 insertions(+), 78 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index d40a9597028c..7b9817eadd8f 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2855,17 +2855,28 @@ def _normalize_launchd_plist_for_comparison(text: str) -> str: def systemd_unit_is_current(system: bool = False) -> bool: + # ── HERMES_HOME sync chokepoint ────────────────────────────────────── + # Every path that compares OR regenerates the unit funnels through here: + # ``refresh_systemd_unit_if_needed`` gates on this before rewriting, and + # ``systemd_status`` / ``systemd_install`` call it directly. Doing the + # sync here — and ONLY here — enforces the invariant "the operator's + # pinned HERMES_HOME is adopted before any compare/regenerate" at a single + # site, so a future callsite cannot regress it by forgetting to pre-sync. + # + # Under ``sudo hermes gateway … --system``, HERMES_HOME is often stripped + # and falls back to ``/root/.hermes``. Adopting the unit's pinned home + # first makes TimeoutStopSec / WorkingDirectory / HERMES_HOME comparisons + # use the real operator config — otherwise start/restart "refresh" rewrites + # a correct unit from root's defaults and ``status`` keeps warning forever. + # ``_sync_...`` is idempotent (early-returns once os.environ matches), so + # the mutation persists for callers that read runtime state after this + # (e.g. ``systemd_restart``'s post-refresh get_running_pid / drain-timeout). + _sync_hermes_home_from_systemd_unit(system=system) + unit_path = get_systemd_unit_path(system=system) if not unit_path.exists(): return False - # Under ``sudo hermes gateway … --system``, HERMES_HOME is often stripped - # and falls back to ``/root/.hermes``. Adopt the unit's pinned home first - # so TimeoutStopSec / WorkingDirectory / HERMES_HOME comparisons use the - # real operator config — otherwise start/restart "refresh" rewrites a - # correct unit from root's defaults and ``status`` keeps warning forever. - _sync_hermes_home_from_systemd_unit(system=system) - installed = unit_path.read_text(encoding="utf-8") expected_user = _read_systemd_user_from_unit(unit_path) if system else None expected = generate_systemd_unit(system=system, run_as_user=expected_user) @@ -2946,11 +2957,10 @@ def refresh_systemd_unit_if_needed(system: bool = False) -> bool: if not unit_path.exists(): return False - # Sync before the current-check / regenerate path so a ``sudo`` shell - # does not bake root's config into a system unit that already pins the - # operator's HERMES_HOME. ``systemd_unit_is_current`` also syncs; doing - # it here keeps the write path safe even if that helper changes. - _sync_hermes_home_from_systemd_unit(system=system) + # The gate below funnels through ``systemd_unit_is_current``, which is the + # single HERMES_HOME-sync chokepoint (adopts the unit's pinned home before + # any compare/regenerate). No separate pre-sync needed here — and the env + # mutation it performs persists for the regenerate path below. if systemd_unit_is_current(system=system): return False @@ -3139,8 +3149,11 @@ def systemd_install( scope_flag = " --system" if system else "" # Existing system units already pin HERMES_HOME; adopt it before any - # current-check / regenerate so ``sudo hermes gateway install --system`` - # cannot "repair" a correct unit from root's config. + # regenerate. This pre-sync is NOT redundant with the systemd_unit_is_current + # chokepoint: the ``--force`` path below skips the is_current gate and calls + # generate_systemd_unit() directly (line ~3172), so without this a + # ``sudo hermes gateway install --system --force`` would bake /root/.hermes + # into an already-correct unit. Keep it to protect that bypass path. if unit_path.exists(): _sync_hermes_home_from_systemd_unit(system=system) @@ -3234,9 +3247,9 @@ def systemd_start(system: bool = False): # Raises UserSystemdUnavailableError with a remediation message. _preflight_user_systemd() _require_service_installed("start", system=system) - # Adopt the unit's HERMES_HOME before refresh so sudo system starts do not - # rewrite TimeoutStopSec / env from /root/.hermes. - _sync_hermes_home_from_systemd_unit(system=system) + # HERMES_HOME sync happens inside refresh_systemd_unit_if_needed's + # systemd_unit_is_current gate (the single chokepoint), and the unit is + # guaranteed to exist here by _require_service_installed, so the gate runs. refresh_systemd_unit_if_needed(system=system) _run_systemctl(["start", get_service_name()], system=system, check=True, timeout=30) print(f"✓ {_service_scope_label(system).capitalize()} service started") @@ -3277,10 +3290,11 @@ def systemd_restart(system: bool = False): else: _preflight_user_systemd() _require_service_installed("restart", system=system) - # Sync BEFORE refresh. Under sudo, refreshing first used to regenerate the - # unit from /root/.hermes (wrong drain timeout / env), then sync for PID - # lookup — leaving ``status`` stuck on "service definition is outdated". - _sync_hermes_home_from_systemd_unit(system=system) + # HERMES_HOME sync happens inside refresh_systemd_unit_if_needed's + # systemd_unit_is_current gate (the single chokepoint). The unit exists + # here (_require_service_installed), so the gate runs and its os.environ + # mutation persists for the get_running_pid / drain-timeout reads below — + # no separate pre-sync needed. refresh_systemd_unit_if_needed(system=system) from gateway.status import get_running_pid @@ -3382,8 +3396,6 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False) print(f" Run: {'sudo ' if system else ''}hermes gateway install{scope_flag}") return - _sync_hermes_home_from_systemd_unit(system=system) - if has_conflicting_systemd_units(): print_systemd_scope_conflict_warning() print() diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index c0298617aaf8..e66c87065d73 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -2074,68 +2074,90 @@ class TestSystemUnitRefreshSyncsHermesHome: assert os.environ["HERMES_HOME"] == str(alice_hermes) assert gateway_cli.systemd_unit_is_current(system=True) is True - def test_systemd_restart_syncs_before_refresh(self, monkeypatch): - calls = [] + def test_is_current_syncs_before_reading_unit(self, tmp_path, monkeypatch): + """CHOKEPOINT INVARIANT: systemd_unit_is_current() must adopt the + unit's pinned HERMES_HOME *before* it reads/compares the unit. - monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: True) - monkeypatch.setattr(gateway_cli, "_require_root_for_system_service", lambda action: None) - monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) - monkeypatch.setattr( - gateway_cli, - "_sync_hermes_home_from_systemd_unit", - lambda system: calls.append(("sync", system)), - ) - monkeypatch.setattr( - gateway_cli, - "refresh_systemd_unit_if_needed", - lambda system=False: calls.append(("refresh", system)), - ) - monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) - monkeypatch.setattr(gateway_cli, "_systemd_main_pid", lambda system=False: None) - monkeypatch.setattr( - gateway_cli, - "_run_systemctl", - lambda args, **kwargs: calls.append(("systemctl", args)) - or SimpleNamespace(returncode=0, stdout="", stderr=""), - ) - monkeypatch.setattr( - gateway_cli, - "_wait_for_systemd_service_restart", - lambda system=False, previous_pid=None: True, - ) + This is the single site that enforces sync-before-compare for every + path (refresh gates on it; status/install call it). If a future edit + moves the sync after the read (or drops it), this test fails. + """ + order = [] + unit_path = tmp_path / "hermes-gateway.service" + unit_path.write_text("[Unit]\n", encoding="utf-8") - gateway_cli.systemd_restart(system=True) + monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) - assert calls[0] == ("sync", True) - assert calls[1] == ("refresh", True) + real_read_text = Path.read_text - def test_systemd_start_syncs_before_refresh(self, monkeypatch): - calls = [] + def tracking_sync(system): + order.append("sync") - monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: True) - monkeypatch.setattr(gateway_cli, "_require_root_for_system_service", lambda action: None) - monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) - monkeypatch.setattr( - gateway_cli, - "_sync_hermes_home_from_systemd_unit", - lambda system: calls.append(("sync", system)), - ) - monkeypatch.setattr( - gateway_cli, - "refresh_systemd_unit_if_needed", - lambda system=False: calls.append(("refresh", system)), - ) - monkeypatch.setattr( - gateway_cli, - "_run_systemctl", - lambda args, **kwargs: calls.append(("systemctl", args)) - or SimpleNamespace(returncode=0, stdout="", stderr=""), - ) + def tracking_read_text(self, *a, **k): + if self == unit_path: + order.append("read") + return real_read_text(self, *a, **k) - gateway_cli.systemd_start(system=True) + monkeypatch.setattr(gateway_cli, "_sync_hermes_home_from_systemd_unit", tracking_sync) + monkeypatch.setattr(Path, "read_text", tracking_read_text) + # Avoid a real generate/compare — we only assert sync precedes read. + monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda **k: "[Unit]\n") + monkeypatch.setattr(gateway_cli, "_read_systemd_user_from_unit", lambda p: None) - assert calls[0] == ("sync", True) - assert calls[1] == ("refresh", True) + gateway_cli.systemd_unit_is_current(system=True) + + assert order, "systemd_unit_is_current did not run sync or read" + assert order[0] == "sync", f"sync must precede unit read; got {order}" + assert "read" in order and order.index("sync") < order.index("read") + + def test_start_and_restart_delegate_sync_to_chokepoint(self, monkeypatch): + """start/restart must NOT pre-sync at the callsite — the sync is owned + by the systemd_unit_is_current chokepoint that refresh gates on. This + pins the single-chokepoint design so a future edit can't reintroduce a + redundant (or, worse, out-of-order) callsite sync. + """ + for entry in ("systemd_start", "systemd_restart"): + calls = [] + monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: True) + monkeypatch.setattr(gateway_cli, "_require_root_for_system_service", lambda action: None) + monkeypatch.setattr( + gateway_cli, "_require_service_installed", lambda action, system=False: None + ) + monkeypatch.setattr( + gateway_cli, + "_sync_hermes_home_from_systemd_unit", + lambda system: calls.append("sync"), + ) + monkeypatch.setattr( + gateway_cli, + "refresh_systemd_unit_if_needed", + lambda system=False: calls.append("refresh"), + ) + monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) + monkeypatch.setattr(gateway_cli, "_systemd_main_pid", lambda system=False: None) + monkeypatch.setattr( + gateway_cli, + "_run_systemctl", + lambda args, **kwargs: calls.append("systemctl") + or SimpleNamespace(returncode=0, stdout="", stderr=""), + ) + monkeypatch.setattr( + gateway_cli, + "_wait_for_systemd_service_restart", + lambda system=False, previous_pid=None: True, + ) + + getattr(gateway_cli, entry)(system=True) + + # refresh runs; the callsite adds NO separate sync before it (the + # chokepoint inside refresh->is_current owns the sync). Here refresh + # is mocked out, so no "sync" should appear at all for the refresh + # phase — proving the callsite pre-sync was removed. + assert "refresh" in calls, f"{entry} must call refresh_systemd_unit_if_needed" + assert calls.count("sync") == 0, ( + f"{entry} should delegate sync to the chokepoint, not pre-sync " + f"at the callsite; got {calls}" + ) class TestHermesHomeForTargetUser: From 3fe7f6d27a005a0a75644c1d1d42cef8c8750c5f Mon Sep 17 00:00:00 2001 From: Umi4Life <poowis2011@hotmail.com> Date: Tue, 16 Jun 2026 17:08:25 +0000 Subject: [PATCH 283/610] fix: preserve fallback switch notice on successful fallback Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- agent/chat_completion_helpers.py | 11 +++ agent/conversation_loop.py | 7 +- run_agent.py | 27 +++++++ tests/run_agent/test_retry_status_buffer.py | 80 +++++++++++++++++++++ 4 files changed, 123 insertions(+), 2 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 9be0a7fed52e..c3df449bdfb7 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1399,6 +1399,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool fb_api_mode = "bedrock_converse" old_model = agent.model + old_provider = agent.provider # Clear the per-config context_length override so the fallback # model's actual context window is resolved instead of inheriting @@ -1544,6 +1545,16 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool f"🔄 Primary model failed — switching to fallback: " f"{fb_model} via {fb_provider}" ) + # The buffered line above is dropped on successful recovery, but a + # provider/model switch is a durable state change operators must see + # even when the fallback succeeds. Record a one-shot notice that the + # success path surfaces exactly once via _emit_pending_fallback_notice + # (see run_agent.py); it is discarded on terminal failure since the + # buffered line is flushed instead. See fallback-observability fix. + agent._pending_fallback_notice = ( + f"🔄 Switched to fallback model: {old_model} via {old_provider} " + f"→ {fb_model} via {fb_provider}" + ) logger.info( "Fallback activated: %s → %s (%s)", old_model, fb_model, fb_provider, diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index cbcb85617013..4295bdad6022 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -5062,8 +5062,11 @@ def run_conversation( # Reset retry counter/signature on successful content agent._empty_content_retries = 0 agent._thinking_prefill_retries = 0 - # Successful content reached — drop any buffered retry - # status from earlier failed attempts in this turn. + # Successful content reached — surface the one-shot fallback + # switch notice (if a fallback activated this turn) before + # dropping the noisy retry buffer, so a provider/model switch + # stays visible even when the fallback succeeds. + agent._emit_pending_fallback_notice() agent._clear_status_buffer() from agent.agent_runtime_helpers import ( diff --git a/run_agent.py b/run_agent.py index 6fb7e0903e5c..61e8657b9a87 100644 --- a/run_agent.py +++ b/run_agent.py @@ -981,6 +981,29 @@ class AIAgent: except Exception: pass + def _emit_pending_fallback_notice(self) -> None: + """Surface the one-shot fallback-switch notice on successful recovery. + + A provider/model switch is a durable state change operators must see, + unlike transient retry chatter that ``_clear_status_buffer`` drops. + ``try_activate_fallback`` records the switch in + ``self._pending_fallback_notice``; this emits it exactly once via + ``_emit_status`` and then clears it, so a successful fallback still + produces one visible notice. On terminal failure the buffered switch + line is flushed instead (and this notice discarded) — see + ``_flush_status_buffer`` — so the user always sees the switch once. + """ + try: + notice = getattr(self, "_pending_fallback_notice", None) + if notice: + # Clear before emitting so a (swallowed) callback error can't + # leave the notice set for a stale re-emit on a later turn. + self._pending_fallback_notice = None + self._emit_status(notice) + except Exception: + # Never break the conversation loop on a notice hiccup. + pass + def _flush_status_buffer(self) -> None: """Emit buffered retry messages — call on terminal failure. @@ -988,6 +1011,10 @@ class AIAgent: was tried before the turn gave up. """ try: + # The buffered trace already carries the fallback switch line, so + # drop any one-shot fallback notice to avoid a stale duplicate + # leaking into a later successful turn. + self._pending_fallback_notice = None buf = getattr(self, "_retry_status_buffer", None) if not buf: return diff --git a/tests/run_agent/test_retry_status_buffer.py b/tests/run_agent/test_retry_status_buffer.py index 221c10c75962..bf116f177b71 100644 --- a/tests/run_agent/test_retry_status_buffer.py +++ b/tests/run_agent/test_retry_status_buffer.py @@ -135,6 +135,86 @@ def test_mixed_kinds_replay_through_correct_channels(): assert warns == ["warn-1"] +def test_pending_fallback_notice_emitted_once_on_success(): + """On successful recovery the one-shot fallback notice is surfaced even + though the noisy retry buffer is dropped.""" + agent = _make_bare_agent() + emitted = [] + agent._emit_status = lambda msg: emitted.append(msg) + + # Simulate try_activate_fallback: buffer the noisy switch line AND record + # the durable one-shot notice. + agent._buffer_status("🔄 Primary model failed — switching to fallback: m2 via p2") + agent._pending_fallback_notice = "🔄 Switched to fallback model: m1 via p1 → m2 via p2" + + # Success path order: emit pending notice, then drop the buffer. + agent._emit_pending_fallback_notice() + agent._clear_status_buffer() + + # The durable notice was shown exactly once; the buffered retry noise was + # silently dropped. + assert emitted == ["🔄 Switched to fallback model: m1 via p1 → m2 via p2"] + assert agent._retry_status_buffer == [] + # Notice is cleared so it cannot re-emit on a later turn. + assert agent._pending_fallback_notice is None + + # A second success path with no new fallback emits nothing. + agent._emit_pending_fallback_notice() + assert emitted == ["🔄 Switched to fallback model: m1 via p1 → m2 via p2"] + + +def test_pending_fallback_notice_noop_when_unset(): + """No fallback this turn → no notice emitted on the success path.""" + agent = _make_bare_agent() + emitted = [] + agent._emit_status = lambda msg: emitted.append(msg) + + # No _pending_fallback_notice attribute set at all. + agent._emit_pending_fallback_notice() + assert emitted == [] + + +def test_flush_discards_pending_fallback_notice(): + """On terminal failure the flushed buffer already carries the switch line, + so the one-shot notice is discarded to avoid a stale duplicate later.""" + agent = _make_bare_agent() + emitted = [] + agent._emit_status = lambda msg: emitted.append(msg) + + agent._buffer_status("🔄 Primary model failed — switching to fallback: m2 via p2") + agent._pending_fallback_notice = "🔄 Switched to fallback model: m1 via p1 → m2 via p2" + + # Terminal failure flushes the buffered trace... + agent._flush_status_buffer() + assert emitted == ["🔄 Primary model failed — switching to fallback: m2 via p2"] + # ...and discards the pending notice so it won't re-emit on a later turn. + assert agent._pending_fallback_notice is None + + emitted.clear() + agent._emit_pending_fallback_notice() + assert emitted == [] + + +def test_pending_fallback_notice_survives_emit_callback_error(): + """A failing status callback must not leave the notice set for a stale + re-emit, and must not raise.""" + agent = _make_bare_agent() + seen = [] + + def boom(msg): + seen.append(msg) + raise RuntimeError("simulated callback failure") + + agent._emit_status = boom + agent._pending_fallback_notice = "🔄 Switched to fallback model: m1 via p1 → m2 via p2" + + # Should not raise. + agent._emit_pending_fallback_notice() + # Attempt was made and the notice is cleared regardless. + assert seen == ["🔄 Switched to fallback model: m1 via p1 → m2 via p2"] + assert agent._pending_fallback_notice is None + + def test_flush_swallows_callback_exceptions(): agent = _make_bare_agent() seen = [] From a4ba8c9640c00f735a2e16bedf14c09e291f8100 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:53:17 +0530 Subject: [PATCH 284/610] =?UTF-8?q?chore:=20map=20poowis2011@hotmail.com?= =?UTF-8?q?=20=E2=86=92=20Umi4Life=20for=20PR=20#47377=20salvage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index db190822baeb..068dd6d10d74 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -57,6 +57,7 @@ AUTHOR_MAP = { "AndreasHiltner@users.noreply.github.com": "AndreasHiltner", # PR #56854 salvage (gateway: route multiplex profile responses through the profile's own adapter — 53-site _adapter_for_source sweep) "allenliang2022@users.noreply.github.com": "allenliang2022", # PR #56932 test coverage folded into #56909 salvage (408 → retryable timeout) "m888.braun@hotmail.com": "ManniBr", # PR #57417 partial salvage (gateway: fail-closed adapter resolution for unregistered secondary profiles) + "poowis2011@hotmail.com": "Umi4Life", # PR #47377 salvage (agent: emit one-shot fallback switch notice on successful fallback so gateway users see model/provider change; #35419) "austin@openvm067.space": "austinlaw076", # PR #57563 partial salvage (auth: lazy per-profile Anthropic OAuth file; gateway: whatsapp_cloud/line added to port-binding platform set) "sunsky.lau@gmail.com": "liuhao1024", # PR #56993 salvage (gateway: process-level HERMES_HOME for pid/lock/status identity files; #56986) "gauravsaxena.jaipur@gmail.com": "gauravsaxena1997", # PR #59868 partial salvage (agent: guard response.text against httpx.ResponseNotRead in _summarize_api_error; #59769) From 0b2b08d54c7bdec42fc29f4a9b1c21adb9add06c Mon Sep 17 00:00:00 2001 From: Shannon Sands <shannon.sands.1979@gmail.com> Date: Thu, 9 Jul 2026 15:50:34 +1000 Subject: [PATCH 285/610] fix(dashboard): recover mobile chat reconnect --- web/src/components/ChatSidebar.tsx | 2 +- web/src/lib/pty-mobile-input.test.ts | 70 +++++++++ web/src/lib/pty-mobile-input.ts | 115 ++++++++++++++ web/src/lib/pty-reconnect.test.ts | 92 +++++++++++ web/src/lib/pty-reconnect.ts | 60 +++++++ web/src/pages/ChatPage.tsx | 225 ++++++++++++++++++++++++--- 6 files changed, 542 insertions(+), 22 deletions(-) create mode 100644 web/src/lib/pty-mobile-input.test.ts create mode 100644 web/src/lib/pty-mobile-input.ts create mode 100644 web/src/lib/pty-reconnect.test.ts create mode 100644 web/src/lib/pty-reconnect.ts diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 47cb5e72b052..e6c6d09e0a1a 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -382,7 +382,7 @@ export function ChatSidebar({ onClick={reconnect} prefix={<RefreshCw />} > - reconnect + reconnect tools feed </Button> )} </div> diff --git a/web/src/lib/pty-mobile-input.test.ts b/web/src/lib/pty-mobile-input.test.ts new file mode 100644 index 000000000000..1b436943902f --- /dev/null +++ b/web/src/lib/pty-mobile-input.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { + normalizePtyMobileInput, + shouldTreatInputAsMobileReplacement, + updatePtyInputLine, +} from "./pty-mobile-input"; + +describe("shouldTreatInputAsMobileReplacement", () => { + it("recognizes explicit browser replacement input", () => { + expect(shouldTreatInputAsMobileReplacement("insertReplacementText", "Kain", false)).toBe(true); + expect(shouldTreatInputAsMobileReplacement("insertFromComposition", "Kain", false)).toBe(true); + expect(shouldTreatInputAsMobileReplacement("insertCompositionText", "Kain", false)).toBe(true); + }); + + it("treats multi-character mobile insertText as replacement-like", () => { + expect(shouldTreatInputAsMobileReplacement("insertText", "Kain", true)).toBe(true); + expect(shouldTreatInputAsMobileReplacement("insertText", "K", true)).toBe(false); + expect(shouldTreatInputAsMobileReplacement("insertText", "Kain", false)).toBe(false); + }); +}); + +describe("normalizePtyMobileInput", () => { + it("turns a Gboard full-line suggestion into a line replacement", () => { + const result = normalizePtyMobileInput( + "hello my name is Kain Kain", + "hello my name is kain", + true, + ); + + expect(result.normalized).toBe(true); + expect(result.nextLine).toBe("hello my name is Kain"); + expect(result.data).toBe("\x7f".repeat("hello my name is kain".length) + "hello my name is Kain"); + }); + + it("turns a Gboard last-word suggestion into a last-word replacement", () => { + const result = normalizePtyMobileInput("Kain", "hello my name is kain", true); + + expect(result.normalized).toBe(true); + expect(result.nextLine).toBe("hello my name is Kain"); + expect(result.data).toBe("\x7f".repeat("hello my name is kain".length) + "hello my name is Kain"); + }); + + it("does not normalize ordinary appends when replacement is not active", () => { + const result = normalizePtyMobileInput( + "hello my name is Kain Kain", + "hello my name is kain", + false, + ); + + expect(result.normalized).toBe(false); + expect(result.nextLine).toBe("hello my name is kainhello my name is Kain Kain"); + }); + + it("does not normalize control input", () => { + const result = normalizePtyMobileInput("\r", "hello", true); + + expect(result.normalized).toBe(false); + expect(result.nextLine).toBe(""); + expect(result.data).toBe("\r"); + }); +}); + +describe("updatePtyInputLine", () => { + it("tracks printable text, delete, and submit", () => { + expect(updatePtyInputLine("", "abc")).toBe("abc"); + expect(updatePtyInputLine("abc", "\x7f")).toBe("ab"); + expect(updatePtyInputLine("abc", "\r")).toBe(""); + }); +}); diff --git a/web/src/lib/pty-mobile-input.ts b/web/src/lib/pty-mobile-input.ts new file mode 100644 index 000000000000..a4991ad6ec28 --- /dev/null +++ b/web/src/lib/pty-mobile-input.ts @@ -0,0 +1,115 @@ +const DELETE = "\x7f"; + +function chars(text: string): string[] { + return Array.from(text); +} + +function removeLastChar(text: string): string { + const c = chars(text); + c.pop(); + return c.join(""); +} + +function isPlainText(data: string): boolean { + return !/[\x00-\x1f\x7f]/.test(data); +} + +function lastWordMatch(line: string): RegExpMatchArray | null { + return line.match(/^(.*?)(\S+)(\s*)$/u); +} + +function collapseDuplicatedFinalWord(text: string, previousLine: string): string { + const match = text.match(/^(.*?)(\S+)(\s+)(\S+)(\s*)$/u); + if (!match) return text; + + const [, prefix, first, , second, trailing] = match; + if (first.toLocaleLowerCase() !== second.toLocaleLowerCase()) return text; + if (!previousLine.trimEnd().toLocaleLowerCase().endsWith(first.toLocaleLowerCase())) { + return text; + } + return `${prefix}${first}${trailing}`; +} + +function replacementLineForMobileInput( + currentLine: string, + incoming: string, +): string | null { + if (!currentLine || currentLine.length < 2 || !incoming) return null; + + const currentLower = currentLine.toLocaleLowerCase(); + const incomingLower = incoming.toLocaleLowerCase(); + + if (incomingLower.startsWith(currentLower)) { + return collapseDuplicatedFinalWord(incoming, currentLine); + } + + const word = lastWordMatch(currentLine); + if (!word) return null; + + const [, prefix, last, trailing] = word; + if (trailing) return null; + + const incomingFirst = incoming.trimStart().split(/\s+/u)[0] ?? ""; + if ( + incomingFirst && + incomingFirst.toLocaleLowerCase() === last.toLocaleLowerCase() + ) { + return `${prefix}${collapseDuplicatedFinalWord(incoming, currentLine)}`; + } + + return null; +} + +export function shouldTreatInputAsMobileReplacement( + inputType: string | undefined, + data: string | null | undefined, + isMobileLike: boolean, +): boolean { + if ( + inputType === "insertReplacementText" || + inputType === "insertFromComposition" || + inputType === "insertCompositionText" + ) { + return true; + } + return isMobileLike && inputType === "insertText" && (data?.length ?? 0) > 1; +} + +export function updatePtyInputLine(currentLine: string, data: string): string { + let next = currentLine; + for (const ch of chars(data)) { + if (ch === "\r" || ch === "\n") { + next = ""; + } else if (ch === DELETE || ch === "\b") { + next = removeLastChar(next); + } else if (ch === "\x15") { + next = ""; + } else if (isPlainText(ch)) { + next += ch; + } + } + return next; +} + +export function normalizePtyMobileInput( + data: string, + currentLine: string, + replacementActive: boolean, +): { data: string; nextLine: string; normalized: boolean } { + if (replacementActive && isPlainText(data)) { + const replacementLine = replacementLineForMobileInput(currentLine, data); + if (replacementLine !== null) { + return { + data: DELETE.repeat(chars(currentLine).length) + replacementLine, + nextLine: replacementLine, + normalized: true, + }; + } + } + + return { + data, + nextLine: updatePtyInputLine(currentLine, data), + normalized: false, + }; +} diff --git a/web/src/lib/pty-reconnect.test.ts b/web/src/lib/pty-reconnect.test.ts new file mode 100644 index 000000000000..3e8fc40648f9 --- /dev/null +++ b/web/src/lib/pty-reconnect.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import { + shouldBlockPtyInput, + shouldReconnectPtyOnPageResume, +} from "./pty-reconnect"; + +describe("shouldReconnectPtyOnPageResume", () => { + it("reconnects a missing socket when the active page becomes visible", () => { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState: null, + ptyState: "reconnecting", + }), + ).toBe(true); + }); + + it("reconnects closed or closing sockets on visible resume", () => { + for (const socketReadyState of [2, 3]) { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState, + ptyState: "reconnecting", + }), + ).toBe(true); + } + }); + + it("does not reconnect an open socket on visible resume", () => { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState: 1, + ptyState: "open", + }), + ).toBe(false); + }); + + it("reconnects a still-connecting socket when the page is already in reconnecting state", () => { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState: 0, + ptyState: "reconnecting", + }), + ).toBe(true); + }); + + it("does not reconnect while the page is hidden", () => { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "hidden", + online: true, + socketReadyState: 3, + ptyState: "reconnecting", + }), + ).toBe(false); + }); + + it("defers reconnect while offline", () => { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: false, + socketReadyState: 3, + ptyState: "reconnecting", + }), + ).toBe(false); + }); +}); + +describe("shouldBlockPtyInput", () => { + it("allows input only while the PTY socket is open", () => { + expect(shouldBlockPtyInput("open")).toBe(false); + expect(shouldBlockPtyInput("connecting")).toBe(true); + expect(shouldBlockPtyInput("reconnecting")).toBe(true); + expect(shouldBlockPtyInput("closed")).toBe(true); + expect(shouldBlockPtyInput("ended")).toBe(true); + }); +}); diff --git a/web/src/lib/pty-reconnect.ts b/web/src/lib/pty-reconnect.ts new file mode 100644 index 000000000000..51bd3bdf38b4 --- /dev/null +++ b/web/src/lib/pty-reconnect.ts @@ -0,0 +1,60 @@ +export type PtyConnectionState = + | "connecting" + | "open" + | "reconnecting" + | "closed" + | "ended"; + +export const PTY_RECONNECT_INPUT_MESSAGE = + "Chat is reconnecting. Input will resume when connected."; + +export interface PtyResumeReconnectInput { + isActive: boolean; + visibilityState?: DocumentVisibilityState; + online: boolean; + socketReadyState?: number | null; + ptyState: PtyConnectionState; +} + +const WS_CONNECTING = 0; +const WS_OPEN = 1; +const WS_CLOSING = 2; +const WS_CLOSED = 3; + +export function shouldReconnectPtyOnPageResume({ + isActive, + visibilityState, + online, + socketReadyState, + ptyState, +}: PtyResumeReconnectInput): boolean { + if (!isActive || !online || visibilityState === "hidden") { + return false; + } + if (ptyState === "ended") { + return false; + } + if (socketReadyState === WS_OPEN) { + return false; + } + if ( + socketReadyState === WS_CONNECTING && + ptyState !== "reconnecting" && + ptyState !== "closed" + ) { + return false; + } + return ( + socketReadyState === null || + socketReadyState === undefined || + socketReadyState === WS_CONNECTING || + socketReadyState === WS_CLOSING || + socketReadyState === WS_CLOSED || + ptyState === "reconnecting" || + ptyState === "closed" + ); +} + +export function shouldBlockPtyInput(ptyState: PtyConnectionState): boolean { + return ptyState !== "open"; +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 708b9df88c22..7a133227c39c 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -36,6 +36,16 @@ import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; import { api } from "@/lib/api"; import { normalizeSessionTitle } from "@/lib/chat-title"; +import { + PTY_RECONNECT_INPUT_MESSAGE, + type PtyConnectionState, + shouldBlockPtyInput, + shouldReconnectPtyOnPageResume, +} from "@/lib/pty-reconnect"; +import { + normalizePtyMobileInput, + shouldTreatInputAsMobileReplacement, +} from "@/lib/pty-mobile-input"; import { PluginSlot } from "@/plugins"; import { useTheme } from "@/themes"; import { useProfileScope } from "@/contexts/useProfileScope"; @@ -160,27 +170,53 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const reconnectAttemptRef = useRef(0); const forceFreshPtyRef = useRef(false); + const blockedInputNoticeRef = useRef(false); + const lastResumeReconnectAtRef = useRef(0); + const ptyInputLineRef = useRef(""); + const mobileReplacementInputUntilRef = useRef(0); + const [ptyState, setPtyState] = + useState<PtyConnectionState>("connecting"); + const ptyStateRef = useRef<PtyConnectionState>("connecting"); + const [lastCloseCode, setLastCloseCode] = useState<number | null>(null); // NS-504: when the agent process exits cleanly (the user typed `/exit`, or // started a new session that ended the current PTY child), the PTY socket // closes with a normal code. Before this fix the terminal just printed // "[session ended]" and went dead — the only recovery was a full page - // refresh. `sessionEnded` flips on that clean close and renders an explicit - // "Start new session" affordance; clicking it bumps `reconnectNonce`, which - // is a dependency of the connect effect, so a fresh PTY spawns in place. - const [sessionEnded, setSessionEnded] = useState(false); + // refresh. `ptyState === "ended"` renders an explicit "Start new session" + // affordance; clicking it bumps `reconnectNonce`, which is a dependency of + // the connect effect, so a fresh PTY spawns in place. const [reconnectNonce, setReconnectNonce] = useState(0); + useEffect(() => { + ptyStateRef.current = ptyState; + }, [ptyState]); const clearReconnectTimer = useCallback(() => { if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } }, []); - const reconnect = useCallback(() => { + const reconnectPty = useCallback(() => { + forceFreshPtyRef.current = false; + reconnectAttemptRef.current = 0; + clearReconnectTimer(); + blockedInputNoticeRef.current = false; + ptyInputLineRef.current = ""; + mobileReplacementInputUntilRef.current = 0; + setBanner(null); + setLastCloseCode(null); + setPtyState("connecting"); + setReconnectNonce((n) => n + 1); + }, [clearReconnectTimer]); + const startFreshPty = useCallback(() => { forceFreshPtyRef.current = true; reconnectAttemptRef.current = 0; clearReconnectTimer(); - setSessionEnded(false); + blockedInputNoticeRef.current = false; + ptyInputLineRef.current = ""; + mobileReplacementInputUntilRef.current = 0; setBanner(null); + setLastCloseCode(null); + setPtyState("connecting"); setReconnectNonce((n) => n + 1); }, [clearReconnectTimer]); const startFreshDashboardChat = useCallback(() => { @@ -190,9 +226,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { forceFreshPtyRef.current = true; reconnectAttemptRef.current = 0; clearReconnectTimer(); + blockedInputNoticeRef.current = false; + ptyInputLineRef.current = ""; + mobileReplacementInputUntilRef.current = 0; setSearchParams(next, { replace: true }); - setSessionEnded(false); setBanner(null); + setLastCloseCode(null); + setPtyState("connecting"); setReconnectNonce((n) => n + 1); }, [clearReconnectTimer, searchParams, setSearchParams]); // Raw state for the mobile side-sheet + a derived value that force- @@ -556,8 +596,43 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { term.loadAddon(new WebLinksAddon()); + let mobileInputCleanup: (() => void) | null = null; term.open(host); + const textarea = term.textarea; + if (textarea) { + textarea.setAttribute("autocomplete", "off"); + textarea.setAttribute("autocorrect", "off"); + textarea.setAttribute("autocapitalize", "off"); + textarea.setAttribute("spellcheck", "false"); + + const isMobileLike = + typeof navigator !== "undefined" && + /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent); + const markReplacementInput = (ev: Event) => { + const input = ev as InputEvent; + if ( + shouldTreatInputAsMobileReplacement( + input.inputType, + input.data, + isMobileLike, + ) + ) { + mobileReplacementInputUntilRef.current = Date.now() + 350; + } + }; + const markCompositionEnd = () => { + mobileReplacementInputUntilRef.current = Date.now() + 350; + }; + + textarea.addEventListener("beforeinput", markReplacementInput, true); + textarea.addEventListener("compositionend", markCompositionEnd, true); + mobileInputCleanup = () => { + textarea.removeEventListener("beforeinput", markReplacementInput, true); + textarea.removeEventListener("compositionend", markCompositionEnd, true); + }; + } + // WebGL draws from a texture atlas sized with device pixels. On phones and // in DevTools device mode that often produces *visually* much larger cells // than `fontSize` suggests — users see "huge" text even at 7–9px settings. @@ -693,10 +768,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const attempt = Math.min(reconnectAttemptRef.current + 1, 5); reconnectAttemptRef.current = attempt; const delayMs = Math.min(250 * 2 ** (attempt - 1), 3000); - setSessionEnded(false); - setBanner( - `Chat connection interrupted (code ${code}). Reconnecting…`, - ); + setBanner(null); + setLastCloseCode(code); + setPtyState("reconnecting"); reconnectTimerRef.current = setTimeout(() => { reconnectTimerRef.current = null; setReconnectNonce((n) => n + 1); @@ -724,7 +798,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { clearReconnectTimer(); reconnectAttemptRef.current = 0; setBanner(null); - setSessionEnded(false); + setLastCloseCode(null); + setPtyState("open"); + blockedInputNoticeRef.current = false; // Connected — cancel any pending reconnect from a prior transient drop. if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); @@ -775,7 +851,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // pty_ws in web_server.py); echo it verbatim alongside the close code. const why = ev.reason ? ` reason=${ev.reason}` : ""; console.warn(`[chat] PTY WebSocket closed code=${ev.code}${why}`); + setLastCloseCode(ev.code); if (ev.code === 4401) { + setPtyState("closed"); setBanner( ev.reason ? `Auth failed (${ev.reason}). Reload to refresh the session.` @@ -785,6 +863,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { } if (ev.code === 4403) { // Host/Origin mismatch (DNS-rebinding guard). + setPtyState("closed"); setBanner( ev.reason ? `Refused: ${ev.reason}.` @@ -793,6 +872,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { return; } if (ev.code === 4404) { + setPtyState("closed"); setBanner( ev.reason ? `Chat websocket unavailable: ${ev.reason}.` @@ -801,6 +881,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { return; } if (ev.code === 4408) { + setPtyState("closed"); setBanner( ev.reason ? `Refused: ${ev.reason}.` @@ -810,6 +891,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { } if (ev.code === 1011) { // Server already wrote an ANSI error frame. + setPtyState("closed"); return; } // Keep-alive close-code contract (web_server.pty_ws + pty_session): @@ -817,10 +899,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // 4409 = superseded by a newer tab attaching the same token → stay quiet. if (ev.code === 4410) { term.write(`\r\n\x1b[90m[session ended]\x1b[0m\r\n`); - setSessionEnded(true); + setPtyState("ended"); return; } if (ev.code === 4409) { + setPtyState("closed"); return; } if (!ev.wasClean || ev.code === 1001 || ev.code === 1006) { @@ -837,7 +920,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { term.write( `\r\n\x1b[90m[session ended (code ${ev.code})]\x1b[0m\r\n`, ); - setSessionEnded(true); + setPtyState("ended"); }; // Keystrokes → PTY. @@ -857,13 +940,33 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/; onDataDisposable = term.onData((data) => { - if (ws.readyState !== WebSocket.OPEN) return; + if ( + ws.readyState !== WebSocket.OPEN || + shouldBlockPtyInput(ptyStateRef.current) + ) { + if (!blockedInputNoticeRef.current) { + blockedInputNoticeRef.current = true; + term.write( + `\r\n\x1b[33m[${PTY_RECONNECT_INPUT_MESSAGE}]\x1b[0m\r\n`, + ); + } + return; + } if (SGR_MOUSE_RE.test(data)) { return; } - ws.send(data); + const normalized = normalizePtyMobileInput( + data, + ptyInputLineRef.current, + Date.now() <= mobileReplacementInputUntilRef.current, + ); + ptyInputLineRef.current = normalized.nextLine; + if (normalized.normalized) { + mobileReplacementInputUntilRef.current = 0; + } + ws.send(normalized.data); }); onResizeDisposable = term.onResize(({ cols, rows }) => { @@ -880,6 +983,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { syncMetricsRef.current = null; onDataDisposable?.dispose(); onResizeDisposable?.dispose(); + mobileInputCleanup?.(); if (metricsDebounce) clearTimeout(metricsDebounce); window.removeEventListener("resize", scheduleSyncTerminalMetrics); window.visualViewport?.removeEventListener( @@ -957,6 +1061,55 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { }; }, [isActive]); + const maybeReconnectOnPageResume = useCallback(() => { + const visibilityState = + typeof document !== "undefined" ? document.visibilityState : "visible"; + const online = + typeof navigator === "undefined" ? true : navigator.onLine !== false; + const socketReadyState = wsRef.current?.readyState ?? null; + + if (banner && ptyStateRef.current === "closed") { + return; + } + + if ( + shouldReconnectPtyOnPageResume({ + isActive, + visibilityState, + online, + socketReadyState, + ptyState: ptyStateRef.current, + }) + ) { + const now = Date.now(); + if (now - lastResumeReconnectAtRef.current < 1000) { + return; + } + lastResumeReconnectAtRef.current = now; + reconnectPty(); + } + }, [banner, isActive, reconnectPty]); + + useEffect(() => { + if (!isActive || typeof window === "undefined") { + return; + } + + const onResume = () => maybeReconnectOnPageResume(); + + document.addEventListener("visibilitychange", onResume); + window.addEventListener("pageshow", onResume); + window.addEventListener("focus", onResume); + window.addEventListener("online", onResume); + + return () => { + document.removeEventListener("visibilitychange", onResume); + window.removeEventListener("pageshow", onResume); + window.removeEventListener("focus", onResume); + window.removeEventListener("online", onResume); + }; + }, [isActive, maybeReconnectOnPageResume]); + // Keep the live xterm theme in sync when the active theme's terminal // colors change (e.g. user switches to a custom YAML theme mid-session). useEffect(() => { @@ -980,6 +1133,15 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // above the app sidebar (`z-50`) and mobile chrome (`z-40`). The main // dashboard column uses `relative z-2`, which traps `position:fixed` // descendants below those layers (see Toast.tsx). + const reconnectBanner = + ptyState === "reconnecting" + ? `Chat connection interrupted${ + lastCloseCode ? ` (code ${lastCloseCode})` : "" + }. Reconnecting...` + : null; + const visibleBanner = banner ?? reconnectBanner; + const showReconnectOverlay = + ptyState === "reconnecting" || (ptyState === "closed" && !banner); const mobileModelToolsPortal = isActive && narrow && @@ -1071,9 +1233,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { <PluginSlot name="chat:top" /> {mobileModelToolsPortal} - {banner && ( + {visibleBanner && ( <div className="border border-warning/50 bg-warning/10 text-warning px-3 py-2 text-xs tracking-wide"> - {banner} + {visibleBanner} </div> )} @@ -1093,16 +1255,37 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { className="hermes-chat-xterm-host min-h-0 min-w-0 flex-1" /> + {showReconnectOverlay && ( + <div className="absolute inset-x-3 top-3 z-20 flex justify-center sm:inset-x-auto sm:right-3 sm:justify-end"> + <div className="flex max-w-[min(28rem,calc(100vw-3rem))] flex-col items-start gap-2 border border-warning/60 bg-black/80 px-3 py-2 text-xs text-warning shadow-lg"> + <div className="tracking-wide"> + {ptyState === "reconnecting" + ? "Chat is reconnecting." + : "Chat disconnected."} + </div> + <Button + size="sm" + outlined + onClick={reconnectPty} + prefix={<RotateCcw className="h-4 w-4" />} + aria-label="Reconnect chat" + > + Reconnect now + </Button> + </div> + </div> + )} + {/* NS-504: the agent process exited (e.g. `/exit` or a new session). Offer an in-place restart so the user never has to refresh the whole page to get a working chat back. */} - {sessionEnded && ( - <div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-black/60"> + {ptyState === "ended" && ( + <div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-black/60"> <div className="text-sm tracking-wide text-white/80"> Session ended. </div> <Button - onClick={reconnect} + onClick={startFreshPty} prefix={<RotateCcw className="h-4 w-4" />} aria-label="Start a new chat session" > From 3e88cae2432ef8235378254f6792c2931ccafd79 Mon Sep 17 00:00:00 2001 From: Shannon Sands <shannon.sands.1979@gmail.com> Date: Thu, 9 Jul 2026 16:06:16 +1000 Subject: [PATCH 286/610] fix(dashboard): harden PTY input tracker against escape sequences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review fixes on the mobile input normalization path: - updatePtyInputLine appended the printable payload of escape sequences (the '[D' of a left-arrow) to the tracked line, and after any cursor movement the flat tracker no longer matched the visual line — the DELETE-repeat replacement could then be computed against a stale snapshot. Any chunk containing ESC now resets the tracker, disarming replacement normalization until a cleanly-tracked line starts. - Move the SGR mouse-report filter ahead of the blocked-input check so scrolling a disconnected terminal doesn't print the reconnect notice. --- web/src/lib/pty-mobile-input.test.ts | 21 +++++++++++++++++++++ web/src/lib/pty-mobile-input.ts | 9 +++++++++ web/src/pages/ChatPage.tsx | 11 +++++++---- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/web/src/lib/pty-mobile-input.test.ts b/web/src/lib/pty-mobile-input.test.ts index 1b436943902f..85ba22007ca2 100644 --- a/web/src/lib/pty-mobile-input.test.ts +++ b/web/src/lib/pty-mobile-input.test.ts @@ -67,4 +67,25 @@ describe("updatePtyInputLine", () => { expect(updatePtyInputLine("abc", "\x7f")).toBe("ab"); expect(updatePtyInputLine("abc", "\r")).toBe(""); }); + + it("resets tracking on escape sequences instead of appending their payload", () => { + // Left-arrow arrives as one CSI chunk; the tracker cannot model cursor + // moves, so it must disarm rather than record "hello[D". + expect(updatePtyInputLine("hello", "\x1b[D")).toBe(""); + expect(updatePtyInputLine("hello", "\x1b[H")).toBe(""); + expect(updatePtyInputLine("hello", "\x1bOP")).toBe(""); + }); +}); + +describe("normalizePtyMobileInput after cursor movement", () => { + it("does not emit a replacement against a tracker reset by arrow keys", () => { + // Simulate: type "hello my name is kain", press left-arrow, then a + // Gboard suggestion arrives. The tracker reset means no replacement + // heuristic can fire against a stale line snapshot. + const afterArrow = updatePtyInputLine("hello my name is kain", "\x1b[D"); + const result = normalizePtyMobileInput("Kain", afterArrow, true); + + expect(result.normalized).toBe(false); + expect(result.data).toBe("Kain"); + }); }); diff --git a/web/src/lib/pty-mobile-input.ts b/web/src/lib/pty-mobile-input.ts index a4991ad6ec28..aaee4c88cbc0 100644 --- a/web/src/lib/pty-mobile-input.ts +++ b/web/src/lib/pty-mobile-input.ts @@ -76,6 +76,15 @@ export function shouldTreatInputAsMobileReplacement( } export function updatePtyInputLine(currentLine: string, data: string): string { + // Escape sequences (arrow keys, home/end, function keys, paste guards) + // move the cursor or edit the line in ways this flat tracker cannot + // model — and the per-char loop below would append their printable + // payload (e.g. the "[D" of a left-arrow) as if it were typed text. + // Reset instead: an unknown cursor position must disarm replacement + // normalization until the user starts a fresh, cleanly-tracked line. + if (data.includes("\x1b")) { + return ""; + } let next = currentLine; for (const ch of chars(data)) { if (ch === "\r" || ch === "\n") { diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 7a133227c39c..9d7ef5c149eb 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -940,6 +940,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/; onDataDisposable = term.onData((data) => { + // Mouse reports (scroll wheel etc.) are not typed input — swallow + // them before the blocked-input check so scrolling a disconnected + // terminal doesn't trip the "reconnecting" notice. + if (SGR_MOUSE_RE.test(data)) { + return; + } + if ( ws.readyState !== WebSocket.OPEN || shouldBlockPtyInput(ptyStateRef.current) @@ -953,10 +960,6 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { return; } - if (SGR_MOUSE_RE.test(data)) { - return; - } - const normalized = normalizePtyMobileInput( data, ptyInputLineRef.current, From 6f42bf344cf51bf99b237f7001f9e18daf5a81c6 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:47:55 +0530 Subject: [PATCH 287/610] fix(dashboard): harden PTY reconnect race, wedged-connect recovery, IME guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up hardening on the salvaged NS-591 mobile-chat reconnect fix, from review findings: - Guard the page-resume reconnect against the async socket-open window: a connectInFlightRef is set synchronously before the ticket-URL await so a visibilitychange/focus fired during that gap (wsRef still null) can't spawn a redundant second socket. Threaded through shouldReconnectPtyOnPageResume as connectInFlight. - Recover a socket wedged in WS_CONNECTING (half-open mobile socket after a radio handoff — the NS-591 scenario) via a PTY_CONNECTING_TIMEOUT_MS force-close so onclose routes into scheduleReconnect. Cleared on open/close/effect-cleanup. - Avoid collapsing legitimate single-letter reduplication ("a a") in the mobile duplicate-final-word heuristic (>=2-char guard). - Extract the 350ms replacement window and 1000ms resume throttle to named exported consts; drop the dead WS_CONNECTING term from the resume predicate's final expression. Adds tests for the in-flight guard and the single-letter reduplication case. --- web/src/lib/pty-mobile-input.test.ts | 9 ++++++ web/src/lib/pty-mobile-input.ts | 10 ++++++ web/src/lib/pty-reconnect.test.ts | 31 +++++++++++++++++++ web/src/lib/pty-reconnect.ts | 20 ++++++++++-- web/src/pages/ChatPage.tsx | 46 ++++++++++++++++++++++++++-- 5 files changed, 111 insertions(+), 5 deletions(-) diff --git a/web/src/lib/pty-mobile-input.test.ts b/web/src/lib/pty-mobile-input.test.ts index 85ba22007ca2..5245a2e3186e 100644 --- a/web/src/lib/pty-mobile-input.test.ts +++ b/web/src/lib/pty-mobile-input.test.ts @@ -59,6 +59,15 @@ describe("normalizePtyMobileInput", () => { expect(result.nextLine).toBe(""); expect(result.data).toBe("\r"); }); + + it("does not collapse legitimate single-letter reduplication", () => { + // "a a" is a plausible thing to type; the >=2-char guard keeps the + // duplicate-final-word collapse from eating it inside the window. + const result = normalizePtyMobileInput("a a", "a", true); + + expect(result.normalized).toBe(false); + expect(result.data).toBe("a a"); + }); }); describe("updatePtyInputLine", () => { diff --git a/web/src/lib/pty-mobile-input.ts b/web/src/lib/pty-mobile-input.ts index aaee4c88cbc0..d6eb024101a7 100644 --- a/web/src/lib/pty-mobile-input.ts +++ b/web/src/lib/pty-mobile-input.ts @@ -1,5 +1,10 @@ const DELETE = "\x7f"; +// How long (ms) after a mobile IME / replacement event we treat subsequent +// terminal input as a candidate line-replacement rather than a plain append. +// Exported so the ChatPage integration and tests share one tunable value. +export const MOBILE_REPLACEMENT_WINDOW_MS = 350; + function chars(text: string): string[] { return Array.from(text); } @@ -24,6 +29,11 @@ function collapseDuplicatedFinalWord(text: string, previousLine: string): string const [, prefix, first, , second, trailing] = match; if (first.toLocaleLowerCase() !== second.toLocaleLowerCase()) return text; + // Only collapse a duplication the tracked line already ended with — i.e. + // Gboard re-emitted the final word. Requiring a >=2-char word avoids + // eating legitimate single-letter reduplication ("a a", "i i") that a + // user may genuinely type inside the replacement window. + if (first.length < 2) return text; if (!previousLine.trimEnd().toLocaleLowerCase().endsWith(first.toLocaleLowerCase())) { return text; } diff --git a/web/src/lib/pty-reconnect.test.ts b/web/src/lib/pty-reconnect.test.ts index 3e8fc40648f9..48543a654024 100644 --- a/web/src/lib/pty-reconnect.test.ts +++ b/web/src/lib/pty-reconnect.test.ts @@ -79,6 +79,37 @@ describe("shouldReconnectPtyOnPageResume", () => { }), ).toBe(false); }); + + it("does not fire a redundant reconnect while a connect is in flight (wsRef not yet assigned)", () => { + // The async socket-open IIFE has begun but not yet assigned wsRef, so + // socketReadyState reads null. Without the connectInFlight guard this + // would return true and double-connect. + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState: null, + ptyState: "connecting", + connectInFlight: true, + }), + ).toBe(false); + }); + + it("still reconnects an in-flight connect when the page already believes it is closed", () => { + // A stuck attempt the user is actively trying to recover (manual reconnect + // or a closed state) must not be suppressed by the in-flight guard. + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState: null, + ptyState: "closed", + connectInFlight: true, + }), + ).toBe(true); + }); }); describe("shouldBlockPtyInput", () => { diff --git a/web/src/lib/pty-reconnect.ts b/web/src/lib/pty-reconnect.ts index 51bd3bdf38b4..8af050493dd8 100644 --- a/web/src/lib/pty-reconnect.ts +++ b/web/src/lib/pty-reconnect.ts @@ -8,12 +8,23 @@ export type PtyConnectionState = export const PTY_RECONNECT_INPUT_MESSAGE = "Chat is reconnecting. Input will resume when connected."; +// Minimum gap (ms) between page-resume-triggered reconnect attempts, so a +// burst of visibilitychange/pageshow/focus/online events on tab-return +// collapses into a single reconnect. +export const PTY_RESUME_RECONNECT_THROTTLE_MS = 1000; + +// If a socket sits in WS_CONNECTING past this budget it is treated as wedged +// (e.g. a half-open mobile socket after a radio handoff — the NS-591 case) +// and force-closed so `onclose` → scheduleReconnect can recover it. +export const PTY_CONNECTING_TIMEOUT_MS = 8000; + export interface PtyResumeReconnectInput { isActive: boolean; visibilityState?: DocumentVisibilityState; online: boolean; socketReadyState?: number | null; ptyState: PtyConnectionState; + connectInFlight?: boolean; } const WS_CONNECTING = 0; @@ -27,6 +38,7 @@ export function shouldReconnectPtyOnPageResume({ online, socketReadyState, ptyState, + connectInFlight, }: PtyResumeReconnectInput): boolean { if (!isActive || !online || visibilityState === "hidden") { return false; @@ -37,8 +49,13 @@ export function shouldReconnectPtyOnPageResume({ if (socketReadyState === WS_OPEN) { return false; } + // A connect is mid-flight (the async socket-open IIFE is awaiting its + // ticket URL and hasn't assigned wsRef yet, or the socket is still + // CONNECTING on a non-stuck attempt). Don't fire a redundant reconnect + // into that window unless the tab already believes it is reconnecting or + // closed and needs a fresh attempt. if ( - socketReadyState === WS_CONNECTING && + (connectInFlight || socketReadyState === WS_CONNECTING) && ptyState !== "reconnecting" && ptyState !== "closed" ) { @@ -47,7 +64,6 @@ export function shouldReconnectPtyOnPageResume({ return ( socketReadyState === null || socketReadyState === undefined || - socketReadyState === WS_CONNECTING || socketReadyState === WS_CLOSING || socketReadyState === WS_CLOSED || ptyState === "reconnecting" || diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 9d7ef5c149eb..68f9221aaa4d 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -37,12 +37,15 @@ import { useI18n } from "@/i18n"; import { api } from "@/lib/api"; import { normalizeSessionTitle } from "@/lib/chat-title"; import { + PTY_CONNECTING_TIMEOUT_MS, PTY_RECONNECT_INPUT_MESSAGE, + PTY_RESUME_RECONNECT_THROTTLE_MS, type PtyConnectionState, shouldBlockPtyInput, shouldReconnectPtyOnPageResume, } from "@/lib/pty-reconnect"; import { + MOBILE_REPLACEMENT_WINDOW_MS, normalizePtyMobileInput, shouldTreatInputAsMobileReplacement, } from "@/lib/pty-mobile-input"; @@ -172,6 +175,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const forceFreshPtyRef = useRef(false); const blockedInputNoticeRef = useRef(false); const lastResumeReconnectAtRef = useRef(0); + // True from the moment the connect effect begins until the socket resolves + // (open or close). Guards the page-resume reconnect against firing during + // the async ticket/URL await gap where wsRef.current is not yet assigned. + const connectInFlightRef = useRef(false); + const connectingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const ptyInputLineRef = useRef(""); const mobileReplacementInputUntilRef = useRef(0); const [ptyState, setPtyState] = @@ -618,11 +626,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { isMobileLike, ) ) { - mobileReplacementInputUntilRef.current = Date.now() + 350; + mobileReplacementInputUntilRef.current = Date.now() + MOBILE_REPLACEMENT_WINDOW_MS; } }; const markCompositionEnd = () => { - mobileReplacementInputUntilRef.current = Date.now() + 350; + mobileReplacementInputUntilRef.current = Date.now() + MOBILE_REPLACEMENT_WINDOW_MS; }; textarea.addEventListener("beforeinput", markReplacementInput, true); @@ -761,6 +769,16 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { let onResizeDisposable: { dispose(): void } | null = null; const forceFresh = forceFreshPtyRef.current; forceFreshPtyRef.current = false; + // A connect attempt is now in flight — set synchronously (before the async + // socket-open IIFE below awaits its ticket URL) so a page-resume event in + // that gap doesn't fire a redundant reconnect (wsRef isn't assigned yet). + connectInFlightRef.current = true; + const clearConnectingTimer = () => { + if (connectingTimerRef.current) { + clearTimeout(connectingTimerRef.current); + connectingTimerRef.current = null; + } + }; const scheduleReconnect = (code: number) => { if (reconnectTimerRef.current) { return; @@ -793,9 +811,26 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const ws = new WebSocket(url); ws.binaryType = "arraybuffer"; wsRef.current = ws; + // W2 (NS-591): a mobile socket can wedge in CONNECTING after a radio + // handoff and never fire onclose, so neither the resume predicate nor + // scheduleReconnect can recover it. Force-close if it hasn't opened + // within the budget; the resulting onclose routes into scheduleReconnect. + clearConnectingTimer(); + connectingTimerRef.current = setTimeout(() => { + connectingTimerRef.current = null; + if (wsRef.current === ws && ws.readyState === WebSocket.CONNECTING) { + try { + ws.close(); + } catch { + /* already tearing down */ + } + } + }, PTY_CONNECTING_TIMEOUT_MS); ws.onopen = () => { clearReconnectTimer(); + clearConnectingTimer(); + connectInFlightRef.current = false; reconnectAttemptRef.current = 0; setBanner(null); setLastCloseCode(null); @@ -842,6 +877,8 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { ws.onclose = (ev) => { wsRef.current = null; + connectInFlightRef.current = false; + clearConnectingTimer(); if (unmounting) { return; } @@ -998,6 +1035,8 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { if (settleRaf1) cancelAnimationFrame(settleRaf1); if (settleRaf2) cancelAnimationFrame(settleRaf2); clearReconnectTimer(); + clearConnectingTimer(); + connectInFlightRef.current = false; // Phase 5.3: ``ws`` is local to the IIFE that opens it (the gated-mode // ticket fetch makes the open async). The cleanup runs at the outer // effect's top level so it can't reach into that scope — close via @@ -1082,10 +1121,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { online, socketReadyState, ptyState: ptyStateRef.current, + connectInFlight: connectInFlightRef.current, }) ) { const now = Date.now(); - if (now - lastResumeReconnectAtRef.current < 1000) { + if (now - lastResumeReconnectAtRef.current < PTY_RESUME_RECONNECT_THROTTLE_MS) { return; } lastResumeReconnectAtRef.current = now; From c889941916e6e96bc3c3fe5b9264f9e88a84735b Mon Sep 17 00:00:00 2001 From: uzaylisak <1torhan@protonmail.com> Date: Thu, 21 May 2026 22:31:40 +0300 Subject: [PATCH 288/610] fix(model_metadata): cache detect_local_server_type result for process lifetime Every 5 minutes fetch_endpoint_model_metadata() re-runs the full server-type waterfall (LM Studio -> Ollama -> llama.cpp -> vLLM), spraying 404s at endpoints the server never exposes (e.g. /api/v1/models and /api/tags on a vllm backend). Add _endpoint_probe_path_cache (base_url -> server type) so the first successful probe's result is reused for the lifetime of the process. Subsequent refreshes skip straight to the known-good path. Fixes #29971. (cherry picked from commit f3d7a8960a93e33683298e51c91c1b22a876d4da) --- agent/model_metadata.py | 84 +++++++++++++++++++++++++---------------- scripts/release.py | 1 + 2 files changed, 52 insertions(+), 33 deletions(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index b60ab1eaedc4..15bc00db8b1f 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -111,6 +111,10 @@ _MODEL_CACHE_TTL = 3600 _endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {} _endpoint_model_metadata_cache_time: Dict[str, float] = {} _ENDPOINT_MODEL_CACHE_TTL = 300 +# Process-lifetime cache: after the first successful probe we remember the +# server type so subsequent refreshes skip the full waterfall (no more 404 +# spam every 5 minutes on non-matching endpoints like /api/v1/models on vllm). +_endpoint_probe_path_cache: Dict[str, str] = {} def _get_model_metadata_cache_path() -> Path: @@ -636,6 +640,10 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: """Detect which local server is running at base_url by probing known endpoints. Returns one of: "ollama", "lm-studio", "vllm", "llamacpp", or None. + + The result is cached for the lifetime of the process so that repeated + calls (e.g. every 5-minute metadata refresh) never re-run the waterfall + and never spray 404s at endpoints the server does not expose. """ import httpx @@ -645,53 +653,63 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: server_url = server_url[:-3] lmstudio_url = _lmstudio_server_root(base_url) + cached = _endpoint_probe_path_cache.get(server_url) + if cached is not None: + return cached + headers = _auth_headers(api_key) + result: Optional[str] = None try: with httpx.Client(timeout=2.0, headers=headers) as client: # LM Studio exposes /api/v1/models — check first (most specific) try: r = client.get(f"{lmstudio_url}/api/v1/models") if r.status_code == 200: - return "lm-studio" + result = "lm-studio" except Exception: pass - # Ollama exposes /api/tags and responds with {"models": [...]} - # LM Studio returns {"error": "Unexpected endpoint"} with status 200 - # on this path, so we must verify the response contains "models". - try: - r = client.get(f"{server_url}/api/tags") - if r.status_code == 200: - try: + if result is None: + # Ollama exposes /api/tags and responds with {"models": [...]} + # LM Studio returns {"error": "Unexpected endpoint"} with status 200 + # on this path, so we must verify the response contains "models". + try: + r = client.get(f"{server_url}/api/tags") + if r.status_code == 200: + try: + data = r.json() + if "models" in data: + result = "ollama" + except Exception: + pass + except Exception: + pass + if result is None: + # llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix) + try: + r = client.get(f"{server_url}/v1/props") + if r.status_code != 200: + r = client.get(f"{server_url}/props") # fallback for older builds + if r.status_code == 200 and "default_generation_settings" in r.text: + result = "llamacpp" + except Exception: + pass + if result is None: + # vLLM: /version + try: + r = client.get(f"{server_url}/version") + if r.status_code == 200: data = r.json() - if "models" in data: - return "ollama" - except Exception: - pass - except Exception: - pass - # llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix) - try: - r = client.get(f"{server_url}/v1/props") - if r.status_code != 200: - r = client.get(f"{server_url}/props") # fallback for older builds - if r.status_code == 200 and "default_generation_settings" in r.text: - return "llamacpp" - except Exception: - pass - # vLLM: /version - try: - r = client.get(f"{server_url}/version") - if r.status_code == 200: - data = r.json() - if "version" in data: - return "vllm" - except Exception: - pass + if "version" in data: + result = "vllm" + except Exception: + pass except Exception: pass - return None + if result is not None: + _endpoint_probe_path_cache[server_url] = result + return result def _iter_nested_dicts(value: Any): diff --git a/scripts/release.py b/scripts/release.py index 068dd6d10d74..22c1854b8df7 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1934,6 +1934,7 @@ AUTHOR_MAP = { "wafy.081107@gmail.com": "mahdiwafy", # PR #60347 salvage (session messages pagination) "codeforgenet@icloud.com": "CodeForgeNet", # PR #47437 salvage (compact_rows blob skip) "i@dex.moe": "dexhunter", # PR #60339 salvage (skills snapshot manifest speedup) + "1torhan@protonmail.com": "uzaylisak", # PR #29988 salvage (detect_local_server_type process-lifetime cache) } From 91ece5c2fc9dc6ef60878cca5c7dab51e40708b0 Mon Sep 17 00:00:00 2001 From: Rod Boev <rod.boev@gmail.com> Date: Tue, 2 Jun 2026 15:04:51 -0400 Subject: [PATCH 289/610] perf(model-metadata): resolve localhost to IPv4 in detect_local_server_type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, `localhost` resolves to both ::1 (IPv6) and 127.0.0.1 (IPv4). httpx tries IPv6 first, hanging 2 sec per probe when the server binds IPv4 only. detect_local_server_type() is called 3+ times during init, each with a new httpx.Client, compounding to ~14s of dead time. Replace localhost with 127.0.0.1 inside the function before connecting. The function is only called for local endpoints (callers guard with is_local_endpoint()), so IPv6 loopback adds no diagnostic value. Measured: 19.9s → 4.0s on Windows with a local proxy on 127.0.0.1:8317. (cherry picked from commit a075d3194bba2e0fa1aa13852862190839bfecef) --- agent/model_metadata.py | 11 +++- tests/agent/test_model_metadata_local_ctx.py | 60 ++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 15bc00db8b1f..f7c3fcf395ec 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -648,10 +648,19 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: import httpx normalized = _normalize_base_url(base_url) + + # Resolve localhost to IPv4 to avoid 2s IPv6 timeout on Windows dual-stack. + # Applied to ``normalized`` before deriving server/LM Studio URLs AND + # before the cache lookup, so localhost and 127.0.0.1 share a cache entry. + normalized = normalized.replace("://localhost:", "://127.0.0.1:") + normalized = normalized.replace("://localhost/", "://127.0.0.1/") + if normalized.endswith("://localhost"): + normalized = normalized[:-len("localhost")] + "127.0.0.1" + server_url = normalized if server_url.endswith("/v1"): server_url = server_url[:-3] - lmstudio_url = _lmstudio_server_root(base_url) + lmstudio_url = _lmstudio_server_root(normalized) cached = _endpoint_probe_path_cache.get(server_url) if cached is not None: diff --git a/tests/agent/test_model_metadata_local_ctx.py b/tests/agent/test_model_metadata_local_ctx.py index 2c069aaf6a4c..879ca4bd2643 100644 --- a/tests/agent/test_model_metadata_local_ctx.py +++ b/tests/agent/test_model_metadata_local_ctx.py @@ -507,6 +507,66 @@ class TestDetectLocalServerTypeAuth: assert client_mock.get.call_args_list[0].args[0] == "http://localhost:1234/api/v1/models" +class TestDetectLocalServerTypeLocalhostIPv4: + """detect_local_server_type should resolve localhost to 127.0.0.1.""" + + def test_localhost_resolved_to_ipv4(self): + """Probes should use 127.0.0.1, not localhost, to avoid IPv6 timeout.""" + from agent.model_metadata import detect_local_server_type + + resp = MagicMock() + resp.status_code = 200 + + client_mock = MagicMock() + client_mock.__enter__ = lambda s: client_mock + client_mock.__exit__ = MagicMock(return_value=False) + client_mock.get.return_value = resp + + with patch("httpx.Client", return_value=client_mock): + detect_local_server_type("http://localhost:8317/v1") + + for call in client_mock.get.call_args_list: + url = call[0][0] + assert "localhost" not in url, f"Probe URL still uses localhost: {url}" + assert "127.0.0.1" in url + + def test_non_localhost_urls_unchanged(self): + """Non-localhost URLs should not be modified.""" + from agent.model_metadata import detect_local_server_type + + client_mock = MagicMock() + client_mock.__enter__ = lambda s: client_mock + client_mock.__exit__ = MagicMock(return_value=False) + resp = MagicMock() + resp.status_code = 404 + client_mock.get.return_value = resp + + with patch("httpx.Client", return_value=client_mock): + detect_local_server_type("http://192.168.1.100:8080") + + for call in client_mock.get.call_args_list: + url = call[0][0] + assert "192.168.1.100" in url + + def test_127_0_0_1_urls_unchanged(self): + """URLs already using 127.0.0.1 should pass through unchanged.""" + from agent.model_metadata import detect_local_server_type + + client_mock = MagicMock() + client_mock.__enter__ = lambda s: client_mock + client_mock.__exit__ = MagicMock(return_value=False) + resp = MagicMock() + resp.status_code = 404 + client_mock.get.return_value = resp + + with patch("httpx.Client", return_value=client_mock): + detect_local_server_type("http://127.0.0.1:8317") + + for call in client_mock.get.call_args_list: + url = call[0][0] + assert "127.0.0.1" in url + + class TestFetchEndpointModelMetadataLmStudio: """fetch_endpoint_model_metadata should use LM Studio's native models endpoint.""" From 9a18a2de12163482e59866dc3f4ed12a21926159 Mon Sep 17 00:00:00 2001 From: Rod Boev <rod.boev@gmail.com> Date: Thu, 9 Jul 2026 13:00:01 +0530 Subject: [PATCH 290/610] fix(agent): probe localhost via IPv4 for LM Studio detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remaining hunk of the PR-branch fixup commit: the LM Studio first-probe assertion now expects the IPv4-resolved URL (the code half — applying the rewrite to `normalized` before deriving lmstudio_url — was folded into the previous cherry-pick's conflict resolution). (cherry picked from commit 7d324b0e47444887fc615e1c428aa30c3dfcd2e8) --- tests/agent/test_model_metadata_local_ctx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/agent/test_model_metadata_local_ctx.py b/tests/agent/test_model_metadata_local_ctx.py index 879ca4bd2643..ccda0a943921 100644 --- a/tests/agent/test_model_metadata_local_ctx.py +++ b/tests/agent/test_model_metadata_local_ctx.py @@ -504,7 +504,7 @@ class TestDetectLocalServerTypeAuth: result = detect_local_server_type("http://localhost:1234/api/v1") assert result == "lm-studio" - assert client_mock.get.call_args_list[0].args[0] == "http://localhost:1234/api/v1/models" + assert client_mock.get.call_args_list[0].args[0] == "http://127.0.0.1:1234/api/v1/models" class TestDetectLocalServerTypeLocalhostIPv4: From c454d32feb266367bc0c30e3711ee19786e5731e Mon Sep 17 00:00:00 2001 From: MiniMax-M3 <zhchl@hermes-agent.local> Date: Mon, 22 Jun 2026 11:20:28 +0800 Subject: [PATCH 291/610] fix(model_tools): honor model.context_length to skip OpenRouter probe on banner _cli's show_banner() calls _resolve_active_context_length() at every startup. For non-OpenRouter providers (e.g. minimax-cn, kimi-coding, custom endpoints) the resolver falls through to step 6 (OpenRouter live /models fetch), which blocks ~2-3s per CLI launch and adds up to 7+ minutes when openrouter.ai is unreachable through a proxy that 403s CONNECT (#46620). Two complementary changes: 1. model_tools.py: read model.context_length from config.yaml and pass it as config_context_length to get_model_context_length. The step-0 config override short-circuits the entire resolution chain including the OpenRouter fetch. No network call is made when the user has set the value explicitly. 2. agent/model_metadata.py: replace flat timeout=10 with (5, 10) tuple at all five sites (fetch_model_metadata + four endpoint probes). urllib3 can otherwise block for 10s per retry stage through proxies that 403 CONNECT. The tuple bounds connect at 5s while still allowing slow reads. Complements the in-flight PR #46685 (which adds HERMES_DISABLE_MODEL_METADATA env var + same timeout tuple change for fetch_model_metadata). This PR extends the timeout fix to the other four endpoint probes and adds the config-override path that addresses the slow-but-reachable scenario where env-var disable is too heavy-handed. Refs #46620, PR #46685. (cherry picked from commit e7faa34199f553d2b1d30c4009856a983cc87707) --- agent/model_metadata.py | 13 ++++++++----- model_tools.py | 8 +++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index f7c3fcf395ec..8396133c9075 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -822,7 +822,10 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any return _model_metadata_cache try: - response = requests.get(OPENROUTER_MODELS_URL, timeout=10, verify=_resolve_requests_verify()) + # Tuple (connect, read) — flat timeout=10 means urllib3 can block 10s per + # retry stage through proxies that 403 CONNECT, ballooning to minutes + # (#46620). 5s connect / 10s read fails fast on unreachable hosts. + response = requests.get(OPENROUTER_MODELS_URL, timeout=(5, 10), verify=_resolve_requests_verify()) response.raise_for_status() data = response.json() @@ -900,7 +903,7 @@ def fetch_endpoint_model_metadata( response = requests.get( server_url.rstrip("/") + "/api/v1/models", headers=headers, - timeout=10, + timeout=(5, 10), verify=_resolve_requests_verify(), ) response.raise_for_status() @@ -948,7 +951,7 @@ def fetch_endpoint_model_metadata( for candidate in candidates: url = candidate.rstrip("/") + "/models" try: - response = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify()) + response = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify()) response.raise_for_status() payload = response.json() cache: Dict[str, Dict[str, Any]] = {} @@ -1715,7 +1718,7 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) -> "x-api-key": api_key, "anthropic-version": "2023-06-01", } - resp = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify()) + resp = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify()) if resp.status_code != 200: return None data = resp.json() @@ -1782,7 +1785,7 @@ def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]: resp = requests.get( "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0", headers={"Authorization": f"Bearer {access_token}"}, - timeout=10, + timeout=(5, 10), verify=_resolve_requests_verify(), ) if resp.status_code != 200: diff --git a/model_tools.py b/model_tools.py index ed236cb2fa4c..c59c189e36d9 100644 --- a/model_tools.py +++ b/model_tools.py @@ -583,7 +583,13 @@ def _resolve_active_context_length() -> int: if not model_id: return 0 from agent.model_metadata import get_model_context_length - return int(get_model_context_length(model_id) or 0) + # Honor explicit `model.context_length` in config.yaml — short-circuits + # the OpenRouter /models probe at get_model_context_length step 0, so + # non-OpenRouter providers don't pay the ~2-3s OpenRouter fetch at every + # CLI startup. See issue #46620. + raw_ctx = model_cfg.get("context_length") + config_ctx = raw_ctx if isinstance(raw_ctx, int) and raw_ctx > 0 else None + return int(get_model_context_length(model_id, config_context_length=config_ctx) or 0) except Exception as e: logger.debug("Could not resolve active context length: %s", e) return 0 From 040e30aa722a64d9c4a9fc4f6316135af2290bab Mon Sep 17 00:00:00 2001 From: Kshitij Kapoor <kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:04:31 +0530 Subject: [PATCH 292/610] perf(model_metadata): cache ollama /api/show probe + normalize context-cache keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up hunks completing the probe-cache cluster: 1. _query_ollama_api_show now goes through the existing _LOCAL_CTX_PROBE_CACHE (30s TTL, positive-only, namespaced key) — it was the one remaining per-resolution POST not covered by the #56431-era wrapper. Failures are never memoized so a server that comes up mid-startup is re-probed. Idea credit: #42081 (@Morad37), reworked to comply with the positive-only rule. 2. Persistent context-cache keys are normalized through _context_cache_key (trailing-slash strip) so http://host/v1 and http://host/v1/ share one entry; reads and invalidation honor legacy un-normalized rows. Idea credit: #37905 (@stevenau21). Tests: TTL hit collapses to one POST, failure-not-memoized (mutation-verified: unconditional caching makes it fail), namespace no-collision vs the sibling probe, slash-variant dedup, legacy-row read, dual-shape invalidation. --- agent/model_metadata.py | 57 +++++++++- tests/agent/test_probe_cache_followups.py | 132 ++++++++++++++++++++++ 2 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 tests/agent/test_probe_cache_followups.py diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 8396133c9075..b911544811f9 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -1052,13 +1052,23 @@ def _load_context_cache() -> Dict[str, int]: return {} +def _context_cache_key(model: str, base_url: str) -> str: + """Canonical ``model@base_url`` key for the persistent context cache. + + Trailing slashes are stripped so ``http://host/v1`` and + ``http://host/v1/`` share one entry instead of creating duplicates + that can go stale independently. + """ + return f"{model}@{(base_url or '').rstrip('/')}" + + def save_context_length(model: str, base_url: str, length: int) -> None: """Persist a discovered context length for a model+provider combo. Cache key is ``model@base_url`` so the same model name served from different providers can have different limits. """ - key = f"{model}@{base_url}" + key = _context_cache_key(model, base_url) cache = _load_context_cache() if cache.get(key) == length: return # already stored @@ -1075,18 +1085,28 @@ def save_context_length(model: str, base_url: str, length: int) -> None: def get_cached_context_length(model: str, base_url: str) -> Optional[int]: """Look up a previously discovered context length for model+provider.""" - key = f"{model}@{base_url}" + key = _context_cache_key(model, base_url) cache = _load_context_cache() - return cache.get(key) + hit = cache.get(key) + if hit is not None: + return hit + # Legacy rows written before key normalization may carry a trailing + # slash — honor them rather than re-probing. + if base_url and base_url != base_url.rstrip("/"): + return cache.get(f"{model}@{base_url}") + return None def _invalidate_cached_context_length(model: str, base_url: str) -> None: """Drop a stale cache entry so it gets re-resolved on the next lookup.""" - key = f"{model}@{base_url}" + key = _context_cache_key(model, base_url) cache = _load_context_cache() - if key not in cache: + # Also clear any legacy un-normalized row for the same pair. + legacy_key = f"{model}@{base_url}" + if key not in cache and legacy_key not in cache: return - del cache[key] + cache.pop(key, None) + cache.pop(legacy_key, None) path = _get_context_cache_path() try: path.parent.mkdir(parents=True, exist_ok=True) @@ -1473,6 +1493,12 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti hosting behind a reverse proxy, etc. For non-Ollama servers the POST returns 404/405 quickly; the function handles errors gracefully. + Results are cached in ``_LOCAL_CTX_PROBE_CACHE`` (same 30s TTL, + positive-only — see ``_query_local_context_length``) so back-to-back + resolutions during one startup issue a single POST instead of one per + call site. Failures are never memoized: a server that isn't up yet must + be re-probed once it comes up. + For hosted servers the GGUF ``model_info.*.context_length`` is the authoritative source: the user can't set their own ``num_ctx``, and the OpenAI-compat ``/v1/models`` endpoint correctly omits ``context_length`` @@ -1484,6 +1510,25 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti The order is flipped vs ``query_ollama_num_ctx()`` because local users control ``num_ctx`` themselves; hosted users can't. """ + import time as _time + + # Namespaced cache key: shares the TTL store with + # _query_local_context_length but never collides with its (model, url) + # keys — the two probes can return different values for the same pair. + cache_key = ("ollama_show", _strip_provider_prefix(model), base_url.rstrip("/")) + now = _time.monotonic() + cached = _LOCAL_CTX_PROBE_CACHE.get(cache_key) + if cached is not None and (now - cached[1]) < _LOCAL_CTX_PROBE_TTL_SECONDS: + return cached[0] + + result = _query_ollama_api_show_uncached(model, base_url, api_key=api_key) + if result: # positive-only — never memoize a failed probe + _LOCAL_CTX_PROBE_CACHE[cache_key] = (result, now) + return result + + +def _query_ollama_api_show_uncached(model: str, base_url: str, api_key: str = "") -> Optional[int]: + """Uncached body of ``_query_ollama_api_show`` — one POST to ``/api/show``.""" import httpx server_url = base_url.rstrip("/") diff --git a/tests/agent/test_probe_cache_followups.py b/tests/agent/test_probe_cache_followups.py new file mode 100644 index 000000000000..faf849b71a4a --- /dev/null +++ b/tests/agent/test_probe_cache_followups.py @@ -0,0 +1,132 @@ +"""Tests for probe-cache follow-ups on the #29988/#37595/#50572 salvage. + +Covers: +- _query_ollama_api_show TTL caching (positive-only, namespaced key) +- persistent context-cache key normalization (trailing-slash dedup) +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +@pytest.fixture(autouse=True) +def _clear_probe_cache(): + """Module-level TTL cache must not leak between tests.""" + from agent import model_metadata + model_metadata._LOCAL_CTX_PROBE_CACHE.clear() + yield + model_metadata._LOCAL_CTX_PROBE_CACHE.clear() + + +def _mock_show_response(ctx=131072): + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = { + "model_info": {"llama.context_length": ctx}, + "parameters": "", + } + return resp + + +def _client_mock(resp): + client = MagicMock() + client.__enter__ = lambda s: client + client.__exit__ = MagicMock(return_value=False) + client.post.return_value = resp + return client + + +class TestOllamaApiShowCaching: + def test_positive_result_cached_within_ttl(self): + from agent.model_metadata import _query_ollama_api_show + + client = _client_mock(_mock_show_response(131072)) + with patch("httpx.Client", return_value=client): + first = _query_ollama_api_show("llama3", "http://127.0.0.1:11434") + second = _query_ollama_api_show("llama3", "http://127.0.0.1:11434") + + assert first == second == 131072 + assert client.post.call_count == 1 # second call served from cache + + def test_failure_never_memoized(self): + """A down server must be re-probed on the next call (startup race).""" + from agent.model_metadata import _query_ollama_api_show + + bad = MagicMock() + bad.status_code = 404 + client = _client_mock(bad) + with patch("httpx.Client", return_value=client): + assert _query_ollama_api_show("llama3", "http://127.0.0.1:11434") is None + assert _query_ollama_api_show("llama3", "http://127.0.0.1:11434") is None + + assert client.post.call_count == 2 # None was NOT cached + + def test_cache_key_does_not_collide_with_local_ctx_probe(self): + """The ollama_show namespace must not read _query_local_context_length rows.""" + from agent import model_metadata + from agent.model_metadata import _query_ollama_api_show + import time as _time + + # Seed a same-(model,url) entry under the sibling probe's key shape. + model_metadata._LOCAL_CTX_PROBE_CACHE[("llama3", "http://127.0.0.1:11434")] = ( + 999, _time.monotonic(), + ) + + client = _client_mock(_mock_show_response(131072)) + with patch("httpx.Client", return_value=client): + result = _query_ollama_api_show("llama3", "http://127.0.0.1:11434") + + assert result == 131072 # probed for real, not the sibling's 999 + assert client.post.call_count == 1 + + +class TestContextCacheKeyNormalization: + def test_trailing_slash_variants_share_one_entry(self, tmp_path, monkeypatch): + from agent import model_metadata + + monkeypatch.setattr( + model_metadata, "_get_context_cache_path", + lambda: tmp_path / "context_lengths.yaml", + ) + + model_metadata.save_context_length("m1", "http://host/v1/", 200_000) + # Both slash variants resolve to the same row. + assert model_metadata.get_cached_context_length("m1", "http://host/v1") == 200_000 + assert model_metadata.get_cached_context_length("m1", "http://host/v1/") == 200_000 + + cache = model_metadata._load_context_cache() + assert list(cache.keys()) == ["m1@http://host/v1"] + + def test_legacy_unnormalized_row_still_honored(self, tmp_path, monkeypatch): + """Rows written pre-normalization (trailing slash in key) must not force a re-probe.""" + import yaml + from agent import model_metadata + + path = tmp_path / "context_lengths.yaml" + monkeypatch.setattr(model_metadata, "_get_context_cache_path", lambda: path) + path.write_text(yaml.dump({"context_lengths": {"m1@http://host/v1/": 128_000}})) + + assert model_metadata.get_cached_context_length("m1", "http://host/v1/") == 128_000 + + def test_invalidate_clears_both_key_shapes(self, tmp_path, monkeypatch): + import yaml + from agent import model_metadata + + path = tmp_path / "context_lengths.yaml" + monkeypatch.setattr(model_metadata, "_get_context_cache_path", lambda: path) + path.write_text(yaml.dump({"context_lengths": { + "m1@http://host/v1": 128_000, + "m1@http://host/v1/": 64_000, + }})) + + model_metadata._invalidate_cached_context_length("m1", "http://host/v1/") + cache = model_metadata._load_context_cache() + assert "m1@http://host/v1" not in cache + assert "m1@http://host/v1/" not in cache From 20cb385328f19ca8cddd5b3e4002bb4b77d373b6 Mon Sep 17 00:00:00 2001 From: Kshitij Kapoor <kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:32:51 +0530 Subject: [PATCH 293/610] fix(model_metadata): widen localhost->IPv4 rewrite to all sibling probe sites #37595 fixed the Windows dual-stack IPv6 timeout only inside detect_local_server_type. The same 2s-per-probe penalty existed at every other helper that builds a probe URL from base_url. Extract the rewrite into _localhost_to_ipv4() and apply it at: - query_ollama_num_ctx - query_ollama_supports_vision - _query_ollama_api_show (server_url derivation) - _query_local_context_length (server root + LM Studio native URL) Tests cover the helper's URL forms, non-localhost passthrough, and that the ollama probes actually POST to 127.0.0.1. --- agent/model_metadata.py | 33 +++++++++++++----- tests/agent/test_model_metadata_local_ctx.py | 2 +- tests/agent/test_probe_cache_followups.py | 35 ++++++++++++++++++++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index b911544811f9..8f6d25944107 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -636,6 +636,24 @@ def is_local_endpoint(base_url: str) -> bool: return False +def _localhost_to_ipv4(url: str) -> str: + """Rewrite a ``localhost`` host to ``127.0.0.1`` in a probe URL. + + On Windows dual-stack machines, httpx resolves ``localhost`` to ``::1`` + first and pays a ~2s IPv6 connect timeout before falling back to IPv4 + when the local server only listens on IPv4 (LM Studio, Ollama defaults). + Probing the IPv4 loopback directly skips that penalty. Non-localhost + URLs pass through unchanged. + """ + if not url: + return url + url = url.replace("://localhost:", "://127.0.0.1:") + url = url.replace("://localhost/", "://127.0.0.1/") + if url.endswith("://localhost"): + url = url[:-len("localhost")] + "127.0.0.1" + return url + + def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: """Detect which local server is running at base_url by probing known endpoints. @@ -652,10 +670,7 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: # Resolve localhost to IPv4 to avoid 2s IPv6 timeout on Windows dual-stack. # Applied to ``normalized`` before deriving server/LM Studio URLs AND # before the cache lookup, so localhost and 127.0.0.1 share a cache entry. - normalized = normalized.replace("://localhost:", "://127.0.0.1:") - normalized = normalized.replace("://localhost/", "://127.0.0.1/") - if normalized.endswith("://localhost"): - normalized = normalized[:-len("localhost")] + "127.0.0.1" + normalized = _localhost_to_ipv4(normalized) server_url = normalized if server_url.endswith("/v1"): @@ -1393,7 +1408,7 @@ def query_ollama_num_ctx(model: str, base_url: str, api_key: str = "") -> Option import httpx bare_model = _strip_provider_prefix(model) - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] @@ -1454,7 +1469,7 @@ def query_ollama_supports_vision(model: str, base_url: str, api_key: str = "") - except Exception: return None - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] @@ -1531,7 +1546,7 @@ def _query_ollama_api_show_uncached(model: str, base_url: str, api_key: str = "" """Uncached body of ``_query_ollama_api_show`` — one POST to ``/api/show``.""" import httpx - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] @@ -1650,10 +1665,10 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str model = _strip_provider_prefix(model) # Strip /v1 suffix to get the server root - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] - lmstudio_url = _lmstudio_server_root(base_url) + lmstudio_url = _localhost_to_ipv4(_lmstudio_server_root(base_url)) headers = _auth_headers(api_key) diff --git a/tests/agent/test_model_metadata_local_ctx.py b/tests/agent/test_model_metadata_local_ctx.py index ccda0a943921..1fcc24ecf824 100644 --- a/tests/agent/test_model_metadata_local_ctx.py +++ b/tests/agent/test_model_metadata_local_ctx.py @@ -465,7 +465,7 @@ class TestQueryLocalContextLengthLmStudio: result = _query_local_context_length("publisher/model-a", "http://localhost:1234/api/v1") assert result == 32768 - assert client_mock.get.call_args_list[0].args[0] == "http://localhost:1234/api/v1/models" + assert client_mock.get.call_args_list[0].args[0] == "http://127.0.0.1:1234/api/v1/models" class TestDetectLocalServerTypeAuth: diff --git a/tests/agent/test_probe_cache_followups.py b/tests/agent/test_probe_cache_followups.py index faf849b71a4a..5dd6ace7bd37 100644 --- a/tests/agent/test_probe_cache_followups.py +++ b/tests/agent/test_probe_cache_followups.py @@ -87,6 +87,41 @@ class TestOllamaApiShowCaching: assert client.post.call_count == 1 +class TestLocalhostIPv4SiblingSites: + """#37595 widened: every probe helper rewrites localhost→127.0.0.1, + not just detect_local_server_type.""" + + def test_helper_rewrites_all_forms(self): + from agent.model_metadata import _localhost_to_ipv4 + + assert _localhost_to_ipv4("http://localhost:1234/v1") == "http://127.0.0.1:1234/v1" + assert _localhost_to_ipv4("http://localhost/v1") == "http://127.0.0.1/v1" + assert _localhost_to_ipv4("http://localhost") == "http://127.0.0.1" + # Non-localhost passes through untouched. + assert _localhost_to_ipv4("http://192.168.1.10:8080") == "http://192.168.1.10:8080" + assert _localhost_to_ipv4("https://api.openai.com/v1") == "https://api.openai.com/v1" + assert _localhost_to_ipv4("") == "" + + def test_ollama_api_show_probes_ipv4(self): + from agent.model_metadata import _query_ollama_api_show + + client = _client_mock(_mock_show_response(131072)) + with patch("httpx.Client", return_value=client): + _query_ollama_api_show("llama3", "http://localhost:11434") + + assert client.post.call_args[0][0].startswith("http://127.0.0.1:11434") + + def test_query_ollama_num_ctx_probes_ipv4(self): + from agent.model_metadata import query_ollama_num_ctx + + client = _client_mock(_mock_show_response(131072)) + with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"), \ + patch("httpx.Client", return_value=client): + query_ollama_num_ctx("llama3", "http://localhost:11434") + + assert client.post.call_args[0][0].startswith("http://127.0.0.1:11434") + + class TestContextCacheKeyNormalization: def test_trailing_slash_variants_share_one_entry(self, tmp_path, monkeypatch): from agent import model_metadata From f556edc10d9b1b41ff3ea8807105155910ab2087 Mon Sep 17 00:00:00 2001 From: Kshitij Kapoor <kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:03:01 +0530 Subject: [PATCH 294/610] fix(model_metadata): address Phase-2 review findings on probe caches Structured review (2a/2b/2c) findings, all fixed: - MAJOR: detect_local_server_type memo was process-lifetime with no invalidation, permanently pinning a URL's server type. Now a bounded 1h TTL ((type, monotonic) tuples) so a backend swap on the same port is re-detected. Test covers ollama->lm-studio swap after expiry. - MAJOR: legacy disk-row compat was one-way. get_cached_context_length and _invalidate_cached_context_length now consult the same key-shape set {canonical, literal, canonical+slash} in both directions, so an old slashed row is found (and cleared) when the runtime passes the normalized URL. Tests pin both migration directions. - MINOR: _localhost_to_ipv4 did whole-string replacement, which could corrupt a proxy URL embedding http://localhost in its query. Now a scheme-anchored host-only regex; localhost.example.com and embedded substrings pass through. Tests added. - MINOR: _invalidate_cached_context_length now also drops the in-memory TTL probe rows for the pair, so a resolution inside the TTL window can't re-persist the value just declared stale. - Test gaps closed: detect-type cache hit + TTL-expiry re-detection, ollama-show TTL expiry re-probe, reverse legacy-row lookups. - Attribution gate: added zhchl@hermes-agent.local -> 8294 (PR #50572 author) to AUTHOR_MAP; the strict CI grep needs bare non-plus emails literal in release.py. Gates: ruff clean; targeted suites 202 passed / 0 failed; full tests/agent 5426 passed with 17 failures identical on clean upstream/main (pre-existing env-dependent anthropic/bedrock/credpool tests); mypy delta vs base: 0 new errors; live smoke 6/6 PASS. --- agent/model_metadata.py | 67 +++++++--- scripts/release.py | 1 + tests/agent/test_probe_cache_followups.py | 148 +++++++++++++++++++++- 3 files changed, 194 insertions(+), 22 deletions(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 8f6d25944107..3e966899b9c8 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -111,10 +111,15 @@ _MODEL_CACHE_TTL = 3600 _endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {} _endpoint_model_metadata_cache_time: Dict[str, float] = {} _ENDPOINT_MODEL_CACHE_TTL = 300 -# Process-lifetime cache: after the first successful probe we remember the +# Bounded-lifetime cache: after the first successful probe we remember the # server type so subsequent refreshes skip the full waterfall (no more 404 # spam every 5 minutes on non-matching endpoints like /api/v1/models on vllm). -_endpoint_probe_path_cache: Dict[str, str] = {} +# Entries expire after _ENDPOINT_PROBE_TTL_SECONDS so a server swap on the +# same port (stop Ollama, start LM Studio) is eventually re-detected instead +# of being pinned to the stale type for the whole process lifetime. +# Values are (server_type, monotonic_timestamp). +_ENDPOINT_PROBE_TTL_SECONDS = 3600.0 +_endpoint_probe_path_cache: Dict[str, tuple] = {} def _get_model_metadata_cache_path() -> Path: @@ -637,21 +642,26 @@ def is_local_endpoint(base_url: str) -> bool: def _localhost_to_ipv4(url: str) -> str: - """Rewrite a ``localhost`` host to ``127.0.0.1`` in a probe URL. + """Rewrite a ``localhost`` HOST to ``127.0.0.1`` in a probe URL. On Windows dual-stack machines, httpx resolves ``localhost`` to ``::1`` first and pays a ~2s IPv6 connect timeout before falling back to IPv4 when the local server only listens on IPv4 (LM Studio, Ollama defaults). - Probing the IPv4 loopback directly skips that penalty. Non-localhost - URLs pass through unchanged. + Probing the IPv4 loopback directly skips that penalty. + + Only the URL's own host component is rewritten (anchored at the scheme), + so a non-localhost URL whose path or query merely embeds the substring + ``http://localhost...`` (e.g. ``?upstream=http://localhost:11434``) + passes through untouched. """ if not url: return url - url = url.replace("://localhost:", "://127.0.0.1:") - url = url.replace("://localhost/", "://127.0.0.1/") - if url.endswith("://localhost"): - url = url[:-len("localhost")] + "127.0.0.1" - return url + return re.sub( + r"^(https?://)localhost(?=[:/]|$)", + r"\g<1>127.0.0.1", + url, + count=1, + ) def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: @@ -678,8 +688,8 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: lmstudio_url = _lmstudio_server_root(normalized) cached = _endpoint_probe_path_cache.get(server_url) - if cached is not None: - return cached + if cached is not None and (time.monotonic() - cached[1]) < _ENDPOINT_PROBE_TTL_SECONDS: + return cached[0] headers = _auth_headers(api_key) @@ -732,7 +742,7 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: pass if result is not None: - _endpoint_probe_path_cache[server_url] = result + _endpoint_probe_path_cache[server_url] = (result, time.monotonic()) return result @@ -1106,9 +1116,15 @@ def get_cached_context_length(model: str, base_url: str) -> Optional[int]: if hit is not None: return hit # Legacy rows written before key normalization may carry a trailing - # slash — honor them rather than re-probing. - if base_url and base_url != base_url.rstrip("/"): - return cache.get(f"{model}@{base_url}") + # slash — honor them rather than re-probing. Checked regardless of the + # caller's slash form: the row's shape and the caller's shape can differ + # in either direction (old slashed row + new normalized config, or the + # reverse), so probe the literal form and the slashed canonical form. + for legacy_key in (f"{model}@{base_url}", f"{key}/"): + if legacy_key != key: + hit = cache.get(legacy_key) + if hit is not None: + return hit return None @@ -1116,12 +1132,21 @@ def _invalidate_cached_context_length(model: str, base_url: str) -> None: """Drop a stale cache entry so it gets re-resolved on the next lookup.""" key = _context_cache_key(model, base_url) cache = _load_context_cache() - # Also clear any legacy un-normalized row for the same pair. - legacy_key = f"{model}@{base_url}" - if key not in cache and legacy_key not in cache: + # Invalidation must also drop the in-memory TTL probe entries for this + # pair — otherwise the next resolution inside the TTL window reuses the + # very value we just declared stale and re-persists it. + bare = _strip_provider_prefix(model) + stripped = (base_url or "").rstrip("/") + _LOCAL_CTX_PROBE_CACHE.pop((bare, stripped), None) + _LOCAL_CTX_PROBE_CACHE.pop(("ollama_show", bare, stripped), None) + # Clear every key shape for this pair: canonical, the caller's literal + # form, and the slashed legacy form — same set get_cached_context_length + # consults, so a lookup can never resurrect a row invalidation missed. + stale_keys = {key, f"{model}@{base_url}", f"{key}/"} + if not any(k in cache for k in stale_keys): return - cache.pop(key, None) - cache.pop(legacy_key, None) + for k in stale_keys: + cache.pop(k, None) path = _get_context_cache_path() try: path.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/release.py b/scripts/release.py index 22c1854b8df7..500c2ca05f55 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1935,6 +1935,7 @@ AUTHOR_MAP = { "codeforgenet@icloud.com": "CodeForgeNet", # PR #47437 salvage (compact_rows blob skip) "i@dex.moe": "dexhunter", # PR #60339 salvage (skills snapshot manifest speedup) "1torhan@protonmail.com": "uzaylisak", # PR #29988 salvage (detect_local_server_type process-lifetime cache) + "zhchl@hermes-agent.local": "8294", # PR #50572 salvage (honor config context_length on banner) } diff --git a/tests/agent/test_probe_cache_followups.py b/tests/agent/test_probe_cache_followups.py index 5dd6ace7bd37..f775b3f877ea 100644 --- a/tests/agent/test_probe_cache_followups.py +++ b/tests/agent/test_probe_cache_followups.py @@ -18,11 +18,13 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @pytest.fixture(autouse=True) def _clear_probe_cache(): - """Module-level TTL cache must not leak between tests.""" + """Module-level caches must not leak between tests.""" from agent import model_metadata model_metadata._LOCAL_CTX_PROBE_CACHE.clear() + model_metadata._endpoint_probe_path_cache.clear() yield model_metadata._LOCAL_CTX_PROBE_CACHE.clear() + model_metadata._endpoint_probe_path_cache.clear() def _mock_show_response(ctx=131072): @@ -68,6 +70,24 @@ class TestOllamaApiShowCaching: assert client.post.call_count == 2 # None was NOT cached + def test_ttl_expiry_reprobes(self): + """After the 30s TTL lapses, the next call must hit the network again.""" + from agent import model_metadata + from agent.model_metadata import _query_ollama_api_show + import time as _time + + client = _client_mock(_mock_show_response(131072)) + with patch("httpx.Client", return_value=client): + _query_ollama_api_show("llama3", "http://127.0.0.1:11434") + # Age the entry past the TTL. + ((key, (val, _ts)),) = list(model_metadata._LOCAL_CTX_PROBE_CACHE.items()) + model_metadata._LOCAL_CTX_PROBE_CACHE[key] = ( + val, _time.monotonic() - model_metadata._LOCAL_CTX_PROBE_TTL_SECONDS - 1, + ) + _query_ollama_api_show("llama3", "http://127.0.0.1:11434") + + assert client.post.call_count == 2 # expired entry re-probed + def test_cache_key_does_not_collide_with_local_ctx_probe(self): """The ollama_show namespace must not read _query_local_context_length rows.""" from agent import model_metadata @@ -87,6 +107,76 @@ class TestOllamaApiShowCaching: assert client.post.call_count == 1 +class TestDetectLocalServerTypeCache: + """#29988: detect_local_server_type memoized with a bounded TTL.""" + + def _get_client(self, server_type="ollama"): + ollama_resp = MagicMock() + ollama_resp.status_code = 200 + ollama_resp.json.return_value = {"models": []} + miss = MagicMock() + miss.status_code = 404 + + client = MagicMock() + client.__enter__ = lambda s: client + client.__exit__ = MagicMock(return_value=False) + + def _get(url, *a, **k): + if url.endswith("/api/tags"): + return ollama_resp + return miss + + client.get.side_effect = _get + return client + + def test_second_call_served_from_cache(self): + from agent.model_metadata import detect_local_server_type + + client = self._get_client() + with patch("httpx.Client", return_value=client): + first = detect_local_server_type("http://127.0.0.1:11434") + calls_after_first = client.get.call_count + second = detect_local_server_type("http://127.0.0.1:11434") + + assert first == second == "ollama" + assert client.get.call_count == calls_after_first # no new HTTP traffic + + def test_ttl_expiry_allows_server_swap_redetection(self): + """Stopping Ollama and starting LM Studio on the same port must be + re-detected once the TTL lapses — the cache is bounded, not + process-lifetime.""" + from agent import model_metadata + from agent.model_metadata import detect_local_server_type + import time as _time + + client = self._get_client() + with patch("httpx.Client", return_value=client): + assert detect_local_server_type("http://127.0.0.1:11434") == "ollama" + + # Age the entry past the TTL, then swap the backend behind the URL. + ((key, (val, _ts)),) = list(model_metadata._endpoint_probe_path_cache.items()) + model_metadata._endpoint_probe_path_cache[key] = ( + val, _time.monotonic() - model_metadata._ENDPOINT_PROBE_TTL_SECONDS - 1, + ) + + lmstudio_resp = MagicMock() + lmstudio_resp.status_code = 200 + lmstudio_resp.json.return_value = {"data": []} + swap_client = MagicMock() + swap_client.__enter__ = lambda s: swap_client + swap_client.__exit__ = MagicMock(return_value=False) + + def _get(url, *a, **k): + if url.endswith("/api/v1/models"): + return lmstudio_resp + miss = MagicMock(); miss.status_code = 404 + return miss + + swap_client.get.side_effect = _get + with patch("httpx.Client", return_value=swap_client): + assert detect_local_server_type("http://127.0.0.1:11434") == "lm-studio" + + class TestLocalhostIPv4SiblingSites: """#37595 widened: every probe helper rewrites localhost→127.0.0.1, not just detect_local_server_type.""" @@ -102,6 +192,18 @@ class TestLocalhostIPv4SiblingSites: assert _localhost_to_ipv4("https://api.openai.com/v1") == "https://api.openai.com/v1" assert _localhost_to_ipv4("") == "" + def test_rewrite_is_host_only_not_substring(self): + """A URL that merely EMBEDS 'http://localhost' in its path/query must + not be corrupted — only the URL's own host is rewritten.""" + from agent.model_metadata import _localhost_to_ipv4 + + proxied = "https://proxy.example.com/route?upstream=http://localhost:11434" + assert _localhost_to_ipv4(proxied) == proxied + # Host must be a full label: localhost.example.com is NOT localhost. + assert _localhost_to_ipv4("http://localhost.example.com/v1") == ( + "http://localhost.example.com/v1" + ) + def test_ollama_api_show_probes_ipv4(self): from agent.model_metadata import _query_ollama_api_show @@ -150,6 +252,18 @@ class TestContextCacheKeyNormalization: assert model_metadata.get_cached_context_length("m1", "http://host/v1/") == 128_000 + def test_legacy_slashed_row_found_with_normalized_caller(self, tmp_path, monkeypatch): + """Reverse migration direction: old row has the slash, current runtime + passes the normalized no-slash URL — must still hit, not re-probe.""" + import yaml + from agent import model_metadata + + path = tmp_path / "context_lengths.yaml" + monkeypatch.setattr(model_metadata, "_get_context_cache_path", lambda: path) + path.write_text(yaml.dump({"context_lengths": {"m1@http://host/v1/": 128_000}})) + + assert model_metadata.get_cached_context_length("m1", "http://host/v1") == 128_000 + def test_invalidate_clears_both_key_shapes(self, tmp_path, monkeypatch): import yaml from agent import model_metadata @@ -165,3 +279,35 @@ class TestContextCacheKeyNormalization: cache = model_metadata._load_context_cache() assert "m1@http://host/v1" not in cache assert "m1@http://host/v1/" not in cache + + def test_invalidate_with_normalized_caller_clears_legacy_row(self, tmp_path, monkeypatch): + """Reverse direction: invalidating with the no-slash URL must also + drop a legacy slashed row, or the next lookup resurrects stale data.""" + import yaml + from agent import model_metadata + + path = tmp_path / "context_lengths.yaml" + monkeypatch.setattr(model_metadata, "_get_context_cache_path", lambda: path) + path.write_text(yaml.dump({"context_lengths": {"m1@http://host/v1/": 64_000}})) + + model_metadata._invalidate_cached_context_length("m1", "http://host/v1") + assert model_metadata.get_cached_context_length("m1", "http://host/v1") is None + assert model_metadata.get_cached_context_length("m1", "http://host/v1/") is None + + def test_invalidate_also_drops_in_memory_probe_entries(self, tmp_path, monkeypatch): + """Disk invalidation must clear the in-memory TTL rows too, or the + next resolution inside the TTL window re-persists the stale value.""" + import time as _time + from agent import model_metadata + + path = tmp_path / "context_lengths.yaml" + monkeypatch.setattr(model_metadata, "_get_context_cache_path", lambda: path) + + now = _time.monotonic() + model_metadata._LOCAL_CTX_PROBE_CACHE[("m1", "http://host/v1")] = (999, now) + model_metadata._LOCAL_CTX_PROBE_CACHE[("ollama_show", "m1", "http://host/v1")] = (999, now) + + model_metadata._invalidate_cached_context_length("m1", "http://host/v1") + + assert ("m1", "http://host/v1") not in model_metadata._LOCAL_CTX_PROBE_CACHE + assert ("ollama_show", "m1", "http://host/v1") not in model_metadata._LOCAL_CTX_PROBE_CACHE From 750c1310a641be10ae33b74d41256a76195a25b1 Mon Sep 17 00:00:00 2001 From: zccyman <zccyman@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:08:25 +0530 Subject: [PATCH 295/610] fix(caching): include Kimi/Moonshot in OpenRouter prompt cache policy (#25970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kimi/Moonshot models on OpenRouter honour the same envelope-layout cache_control markers as Claude on OpenRouter, but the policy fell through to (False, False) — serving ~1% cache hits on 64K-token prompts and re-billing the full prompt every turn. Observed within-turn progression with cache enabled: 1% -> 67% -> 84% -> 97%. (cherry picked from commit 3b857a35e, agent/agent_runtime_helpers.py hunk only; the original PR #26014 branch also carried unrelated .dev-workflow artifacts which are intentionally not included) --- agent/agent_runtime_helpers.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 6f57f83e9774..fc56ad3ca905 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1564,6 +1564,13 @@ def anthropic_prompt_cache_policy( model_lower = eff_model.lower() provider_lower = eff_provider.lower() is_claude = "claude" in model_lower + # Kimi / Moonshot family via OpenRouter: same cache_control wire format + # as Claude on OpenRouter (envelope layout). Without this branch + # moonshotai/kimi-k2.6 falls through to (False, False), serving ~1% + # cache hits on 64K-token prompts and re-billing the full prompt on + # every turn. Observed within-turn progression with cache enabled: + # 1% → 67% → 84% → 97% (#25970). + is_kimi = "kimi" in model_lower or "moonshot" in model_lower is_openrouter = base_url_host_matches(eff_base_url, "openrouter.ai") # Nous Portal proxies to OpenRouter behind the scenes — identical # OpenAI-wire envelope cache_control semantics. Treat it as an @@ -1577,7 +1584,7 @@ def anthropic_prompt_cache_policy( if is_native_anthropic: return True, True - if (is_openrouter or is_nous_portal) and is_claude: + if (is_openrouter or is_nous_portal) and (is_claude or is_kimi): return True, False # Nous Portal Qwen (e.g. qwen3.6-plus) takes the same envelope-layout # cache_control path as Portal Claude. Portal proxies to OpenRouter From fbbb8415c32730a3006db69284bf55f75aaef972 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:09:12 +0530 Subject: [PATCH 296/610] test(caching): pin Kimi/Moonshot OpenRouter cache policy (#25970) Adapted from PR #26014's test file to the canonical seam (tests/run_agent/test_anthropic_prompt_cache_policy.py _make_agent helper) instead of a new top-level file with sys.path manipulation. Covers: kimi-k2.6 + moonshot-v1 on OpenRouter (envelope layout), kimi via Nous Portal, and the non-OpenRouter negative case. --- .../test_anthropic_prompt_cache_policy.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/run_agent/test_anthropic_prompt_cache_policy.py b/tests/run_agent/test_anthropic_prompt_cache_policy.py index ba6e54f03720..56e146d358cf 100644 --- a/tests/run_agent/test_anthropic_prompt_cache_policy.py +++ b/tests/run_agent/test_anthropic_prompt_cache_policy.py @@ -75,6 +75,46 @@ class TestOpenRouter: assert agent._anthropic_prompt_cache_policy() == (False, False) +class TestKimiMoonshotOnOpenRouter: + """Kimi/Moonshot on OpenRouter honour envelope-layout cache_control (#25970).""" + + def test_kimi_k26_on_openrouter_caches_with_envelope_layout(self): + agent = _make_agent( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + model="moonshotai/kimi-k2.6", + ) + assert agent._anthropic_prompt_cache_policy() == (True, False) + + def test_moonshot_v1_on_openrouter_caches_with_envelope_layout(self): + agent = _make_agent( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + model="moonshotai/moonshot-v1-8k", + ) + assert agent._anthropic_prompt_cache_policy() == (True, False) + + def test_kimi_on_nous_portal_caches_with_envelope_layout(self): + agent = _make_agent( + provider="nous", + base_url="https://api.nousresearch.com/v1", + api_mode="chat_completions", + model="moonshotai/kimi-k2.6", + ) + assert agent._anthropic_prompt_cache_policy() == (True, False) + + def test_kimi_on_non_openrouter_host_does_not_cache(self): + agent = _make_agent( + provider="custom", + base_url="https://api.moonshot.cn/v1", + api_mode="chat_completions", + model="moonshotai/kimi-k2.6", + ) + assert agent._anthropic_prompt_cache_policy() == (False, False) + + class TestThirdPartyAnthropicGateway: """Third-party gateways speaking the Anthropic protocol (MiniMax, Zhipu GLM, LiteLLM).""" From 1d689e19203281228878ac6770d4a6700d4ae385 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:27:27 +0530 Subject: [PATCH 297/610] fix(caching): use canonical Kimi-family matcher in cache policy Review finding: the substring check ('kimi' or 'moonshot' in model) under-matches bare release slugs like k2-thinking that the repo's canonical _model_name_is_kimi_family matcher (anthropic_adapter.py) already covers. Reuse it instead of a second ad-hoc matcher; add a regression test for the bare-slug case. --- agent/agent_runtime_helpers.py | 8 ++++++-- tests/run_agent/test_anthropic_prompt_cache_policy.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index fc56ad3ca905..3e5cd63536bb 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1569,8 +1569,12 @@ def anthropic_prompt_cache_policy( # moonshotai/kimi-k2.6 falls through to (False, False), serving ~1% # cache hits on 64K-token prompts and re-billing the full prompt on # every turn. Observed within-turn progression with cache enabled: - # 1% → 67% → 84% → 97% (#25970). - is_kimi = "kimi" in model_lower or "moonshot" in model_lower + # 1% → 67% → 84% → 97% (#25970). Reuses the canonical family matcher + # (covers bare k1./k2./k25 release slugs the substring check missed). + from agent.anthropic_adapter import _model_name_is_kimi_family + is_kimi = ( + _model_name_is_kimi_family(eff_model) or "moonshot" in model_lower + ) is_openrouter = base_url_host_matches(eff_base_url, "openrouter.ai") # Nous Portal proxies to OpenRouter behind the scenes — identical # OpenAI-wire envelope cache_control semantics. Treat it as an diff --git a/tests/run_agent/test_anthropic_prompt_cache_policy.py b/tests/run_agent/test_anthropic_prompt_cache_policy.py index 56e146d358cf..679a9219fbe9 100644 --- a/tests/run_agent/test_anthropic_prompt_cache_policy.py +++ b/tests/run_agent/test_anthropic_prompt_cache_policy.py @@ -105,6 +105,17 @@ class TestKimiMoonshotOnOpenRouter: ) assert agent._anthropic_prompt_cache_policy() == (True, False) + def test_kimi_bare_release_slug_on_openrouter_caches(self): + """Bare release slugs (k2-thinking) lack the 'kimi'/'moonshot' substring; + the canonical family matcher must still catch them.""" + agent = _make_agent( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + model="k2-thinking", + ) + assert agent._anthropic_prompt_cache_policy() == (True, False) + def test_kimi_on_non_openrouter_host_does_not_cache(self): agent = _make_agent( provider="custom", From 74609f926c353129c73bfc3704f0deed152733fa Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:27:35 +0530 Subject: [PATCH 298/610] fix(dashboard): run residual cron profile I/O off the event loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two async handlers still called the cron profile-walk helpers directly on the FastAPI event loop after the 49fa04a23/346e5673d threadpool migration: - POST /api/cron/fire called _find_cron_job_profile() inline — it walks every profile and lists its jobs (file I/O per profile), stalling the loop before the 202 is returned. - POST /api/cron/blueprints/instantiate called _call_cron_for_profile() inline for create_job. Route both through the existing _run_cron_dashboard_io threadpool wrapper like every other cron dashboard endpoint. Credit: @riceharvest (#50948) originally identified the sync-I/O-in-async- handlers bug class for the desktop boot endpoints; 49fa04a23, 346e5673d, 7d0ddbb2f, d5eee133e and 24d5bda1e have since fixed most of that PR's scope via the managed threadpool + PID cache + alias-map surfaces. This covers the two cron handlers those merges missed. --- hermes_cli/web_server.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index fa270747528e..2ba21a8ed959 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -10481,7 +10481,10 @@ async def cron_fire_webhook(request: Request): if not job_id: return JSONResponse({"error": "missing job_id"}, status_code=400) - profile = _find_cron_job_profile(job_id) + # _find_cron_job_profile walks every profile and lists its jobs (file + # I/O per profile) — run it off the event loop like the other cron + # dashboard endpoints. + profile = await _run_cron_dashboard_io(_find_cron_job_profile, job_id) if not profile: # Job is gone (cancelled / completed) — nothing to fire. 200 so NAS # does not retry a fire that is intentionally absent. @@ -10556,7 +10559,11 @@ async def instantiate_blueprint(body: AutomationBlueprintInstantiate, profile: s # Blueprint-created jobs deliver to the dashboard's configured target by # default; the form's deliver slot overrides via spec["deliver"]. spec.pop("origin", None) - return _call_cron_for_profile(profile, "create_job", **spec) + # create_job does per-profile file I/O — keep it off the event loop + # like the sibling cron endpoints (partial avoids **spec keys ever + # colliding with the wrapper's own parameters). + _create = functools.partial(_call_cron_for_profile, profile, "create_job", **spec) + return await _run_cron_dashboard_io(_create) except HTTPException: raise except Exception as e: From 8cfada0df4e7034bc86e47efbbdeb79578d754a2 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:30:16 +0530 Subject: [PATCH 299/610] test(dashboard): pin cron fire + blueprint handlers off the event loop Mutation-verified: both tests fail against main's inline-call version and pass with the threadpool routing. --- .../test_cron_dashboard_off_loop.py | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/hermes_cli/test_cron_dashboard_off_loop.py diff --git a/tests/hermes_cli/test_cron_dashboard_off_loop.py b/tests/hermes_cli/test_cron_dashboard_off_loop.py new file mode 100644 index 000000000000..f0efb1d1b94e --- /dev/null +++ b/tests/hermes_cli/test_cron_dashboard_off_loop.py @@ -0,0 +1,87 @@ +"""Regression tests: cron dashboard handlers must not run profile I/O on the event loop. + +Guards the residual sites missed by the 49fa04a23/346e5673d threadpool +migration: POST /api/cron/fire (_find_cron_job_profile) and +POST /api/cron/blueprints/instantiate (_call_cron_for_profile create_job). +Each stub asserts it is running OFF the event loop thread by checking that +no running asyncio loop is present in its thread. +""" + +import asyncio + +import pytest +from starlette.testclient import TestClient + +from hermes_cli import web_server + + +@pytest.fixture() +def loop_probe(): + """Collect (tag, on_loop) proof from stubbed profile-I/O helpers.""" + seen = [] + + def probe(tag): + try: + asyncio.get_running_loop() + seen.append((tag, True)) + except RuntimeError: + seen.append((tag, False)) + + return seen, probe + + +def test_cron_fire_profile_lookup_off_loop(monkeypatch, loop_probe): + seen, probe = loop_probe + + def fake_find(job_id): + probe("find") + return None + + monkeypatch.setattr(web_server, "_find_cron_job_profile", fake_find) + + import plugins.cron_providers.chronos.verify as chv + monkeypatch.setattr(chv, "get_fire_verifier", lambda: (lambda **kw: {"sub": "t"})) + + client = TestClient(web_server.app) + resp = client.post( + "/api/cron/fire", + json={"job_id": "missing-job"}, + headers={"Authorization": "Bearer x"}, + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "gone" + assert ("find", False) in seen, ( + f"_find_cron_job_profile must run off the event loop; proof: {seen}" + ) + + +def test_blueprint_instantiate_create_job_off_loop(monkeypatch, loop_probe): + seen, probe = loop_probe + + def fake_call(profile, fn, *args, **kwargs): + probe("call") + return {"id": "bp-job-1", "kwargs_seen": sorted(kwargs.keys())} + + monkeypatch.setattr(web_server, "_call_cron_for_profile", fake_call) + monkeypatch.setattr(web_server, "_has_valid_session_token", lambda req: True) + + import cron.blueprint_catalog as bc + monkeypatch.setattr(bc, "get_blueprint", lambda key: object()) + monkeypatch.setattr( + bc, + "fill_blueprint", + lambda bp, vals: {"name": "t", "schedule": "0 9 * * *", "prompt": "hi"}, + ) + + client = TestClient(web_server.app) + resp = client.post( + "/api/cron/blueprints/instantiate", + json={"blueprint": "morning-brief", "values": {}}, + ) + assert resp.status_code == 200 + body = resp.json() + # **spec kwargs must arrive at create_job intact through the partial. + assert body["kwargs_seen"] == ["name", "prompt", "schedule"] + assert ("call", False) in seen, ( + f"_call_cron_for_profile must run off the event loop; proof: {seen}" + ) From d928017742d5cf242998e1667c994a23df85d80e Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:34:01 +0530 Subject: [PATCH 300/610] fix(dashboard): validate HERMES_WEB_DIST before startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom HERMES_WEB_DIST without --skip-build skipped BOTH the web UI build and any validation: cmd_dashboard fell through the build gate and started the server against a dist that may not exist, serving 404s with no obvious cause. This is the same failure mode issue #23817 fixed for the --skip-build branch — the env-var branch was left unvalidated. Add the missing else-branch: fail fast with actionable guidance when HERMES_WEB_DIST has no index.html, proceed (still without building) when it does. Credit: @Caelier (#17845) originally proposed dist validation for the dashboard startup path; the --skip-build half of that PR's scope has since landed via the #23817 fix, this covers the remaining env-var path on the rewritten cmd_dashboard surface. --- hermes_cli/main.py | 13 ++ .../test_dashboard_web_dist_validation.py | 113 ++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 tests/hermes_cli/test_dashboard_web_dist_validation.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f21388c21812..47534b363882 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12080,6 +12080,19 @@ def cmd_dashboard(args): print(" Or drop --skip-build to build automatically.") sys.exit(1) print(f"→ Skipping web UI build (--skip-build); using dist at {_dist_root}") + else: + # HERMES_WEB_DIST is set without --skip-build: the build is skipped + # (the env var points at a caller-managed dist), so validate it the + # same way the --skip-build branch does — otherwise the server starts + # and serves 404s with no obvious cause (same failure mode as #23817, + # via the env-var path). + _dist_root = Path(os.environ["HERMES_WEB_DIST"]).expanduser() + if not (_dist_root / "index.html").exists(): + print(f"✗ HERMES_WEB_DIST is set but no web dist found at: {_dist_root}") + print(" Pre-build first: npm install --workspace web && npm run build -w web") + print(" Or unset HERMES_WEB_DIST to build and use the default web UI dist.") + sys.exit(1) + print(f"→ Using web dist from HERMES_WEB_DIST: {_dist_root}") # Discover and load plugins so any DashboardAuthProvider plugin # (e.g. plugins/dashboard_auth/nous) registers BEFORE start_server's diff --git a/tests/hermes_cli/test_dashboard_web_dist_validation.py b/tests/hermes_cli/test_dashboard_web_dist_validation.py new file mode 100644 index 000000000000..92c63fe3e357 --- /dev/null +++ b/tests/hermes_cli/test_dashboard_web_dist_validation.py @@ -0,0 +1,113 @@ +"""Regression tests: `hermes dashboard` validates HERMES_WEB_DIST before serving. + +A custom HERMES_WEB_DIST without --skip-build previously skipped BOTH the +build and any validation, so the server started and served 404s with no +obvious cause (same failure mode as issue #23817, reached via the env-var +path instead of --skip-build). The env-var branch must now fail fast when +the dist has no index.html, and proceed when it does. + +Design credit: PR #17845 (@Caelier). +""" + +import sys +import types + +import pytest + + +@pytest.fixture() +def main_mod(): + import hermes_cli.main as main + return main + + +def _args(**over): + base = { + "host": "127.0.0.1", + "port": 0, + "no_open": True, + "open_profile": None, + "skip_build": False, + "headless_backend": False, + "tui": False, + } + base.update(over) + return types.SimpleNamespace(**base) + + +def _wire_common(main_mod, monkeypatch): + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "default" + ) + monkeypatch.setattr(main_mod, "_sync_bundled_skills_quietly", lambda: None) + monkeypatch.setitem(sys.modules, "fastapi", types.SimpleNamespace()) + monkeypatch.setitem(sys.modules, "uvicorn", types.SimpleNamespace()) + monkeypatch.setitem( + sys.modules, + "hermes_logging", + types.SimpleNamespace(setup_logging=lambda **_k: None), + ) + monkeypatch.setitem( + sys.modules, + "hermes_cli.plugins", + types.SimpleNamespace(discover_plugins=lambda: None), + ) + monkeypatch.setattr( + "hermes_cli.mcp_startup.start_background_mcp_discovery", + lambda **_k: None, + ) + + +def test_env_dist_without_index_exits(main_mod, monkeypatch, tmp_path, capsys): + """HERMES_WEB_DIST pointing at a dist with no index.html must exit 1, + not start a server that 404s.""" + _wire_common(main_mod, monkeypatch) + empty_dist = tmp_path / "empty_dist" + empty_dist.mkdir() + monkeypatch.setenv("HERMES_WEB_DIST", str(empty_dist)) + + started = [] + monkeypatch.setitem( + sys.modules, + "hermes_cli.web_server", + types.SimpleNamespace(start_server=lambda **k: started.append(k)), + ) + builds = [] + monkeypatch.setattr( + main_mod, "_build_web_ui", lambda *a, **k: builds.append(a) or True + ) + + with pytest.raises(SystemExit) as exc: + main_mod.cmd_dashboard(_args()) + + assert exc.value.code == 1 + assert started == [] + assert builds == [] # env var set -> build skipped, validation is the gate + out = capsys.readouterr().out + assert "HERMES_WEB_DIST" in out and str(empty_dist) in out + + +def test_env_dist_with_index_starts_server(main_mod, monkeypatch, tmp_path): + """A valid HERMES_WEB_DIST (has index.html) proceeds to start_server + without building.""" + _wire_common(main_mod, monkeypatch) + dist = tmp_path / "dist" + dist.mkdir() + (dist / "index.html").write_text("<html></html>", encoding="utf-8") + monkeypatch.setenv("HERMES_WEB_DIST", str(dist)) + + started = [] + monkeypatch.setitem( + sys.modules, + "hermes_cli.web_server", + types.SimpleNamespace(start_server=lambda **k: started.append(k)), + ) + builds = [] + monkeypatch.setattr( + main_mod, "_build_web_ui", lambda *a, **k: builds.append(a) or True + ) + + main_mod.cmd_dashboard(_args()) + + assert len(started) == 1 + assert builds == [] From 4ed910c6894f602cf7b9b4533226dedda4d2db3d Mon Sep 17 00:00:00 2001 From: Tranquil-Flow <tranquil_flow@protonmail.com> Date: Sat, 25 Apr 2026 08:53:10 +1000 Subject: [PATCH 301/610] fix(cli): mock systemd preflight in gateway service tests for non-systemd environments (#15187) Mock _preflight_user_systemd and _select_systemd_scope in test_systemd_start_refreshes_outdated_unit and test_systemd_restart_refreshes_outdated_unit. These tests target unit-file refresh logic, not D-Bus reachability, so the preflight check was causing spurious UserSystemdUnavailableError on macOS, WSL, and Docker where systemd is unavailable. (cherry picked from commit 34113300a1cbbecf7c9fa2201ddaf517acc521d3) --- tests/hermes_cli/test_gateway_service.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index e66c87065d73..ef563c34b49a 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -68,6 +68,10 @@ class TestSystemdServiceRefresh: monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda system=False, run_as_user=None: "new unit\n") + # Bypass systemd availability checks — this test targets unit-file + # refresh logic, not D-Bus reachability (fails on macOS/WSL/Docker). + monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda **kw: None) + monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) calls = [] @@ -91,6 +95,10 @@ class TestSystemdServiceRefresh: monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda system=False, run_as_user=None: "new unit\n") + # Bypass systemd availability checks — this test targets unit-file + # refresh logic, not D-Bus reachability (fails on macOS/WSL/Docker). + monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda **kw: None) + monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) calls = [] monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) From 724ab9098dfbf6bb4ebbf5de5733381dc64257b9 Mon Sep 17 00:00:00 2001 From: pedrommaiaa <100535780+pedrommaiaa@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:25:44 -0400 Subject: [PATCH 302/610] perf: avoid broad message prep deepcopies (cherry picked from commit 030746c56010ce9f343140a16dfe66a7966b0705) --- agent/prompt_caching.py | 50 +++++++++-- agent/transports/chat_completions.py | 69 +++++++++++---- .../model-providers/qwen-oauth/__init__.py | 54 ++++++++---- tests/agent/test_prompt_caching.py | 48 +++++++++++ .../agent/transports/test_chat_completions.py | 84 ++++++++++++++++++- tests/providers/test_provider_profiles.py | 63 ++++++++++++++ tests/run_agent/test_provider_parity.py | 10 +++ 7 files changed, 341 insertions(+), 37 deletions(-) diff --git a/agent/prompt_caching.py b/agent/prompt_caching.py index 9a2fdf4ccce6..34d34143127f 100644 --- a/agent/prompt_caching.py +++ b/agent/prompt_caching.py @@ -8,7 +8,6 @@ single session. Pure functions -- no class state, no AIAgent dependency. """ -import copy from typing import Any, Dict, List @@ -81,6 +80,41 @@ def _build_marker(ttl: str) -> Dict[str, str]: return marker +def _copy_message_for_api_mutation(msg: Dict[str, Any]) -> Dict[str, Any]: + """Copy message containers that later API-retry recovery may mutate.""" + copied = dict(msg) + + content = msg.get("content") + if isinstance(content, list) and content: + copied_content = [] + for part in content: + if isinstance(part, dict): + copied_part = dict(part) + image_url = part.get("image_url") + if isinstance(image_url, dict): + copied_part["image_url"] = dict(image_url) + copied_content.append(copied_part) + else: + copied_content.append(part) + copied["content"] = copied_content + + tool_calls = msg.get("tool_calls") + if isinstance(tool_calls, list) and tool_calls: + copied_tool_calls = [] + for tool_call in tool_calls: + if isinstance(tool_call, dict): + copied_tool_call = dict(tool_call) + function = tool_call.get("function") + if isinstance(function, dict): + copied_tool_call["function"] = dict(function) + copied_tool_calls.append(copied_tool_call) + else: + copied_tool_calls.append(tool_call) + copied["tool_calls"] = copied_tool_calls + + return copied + + def apply_anthropic_cache_control( api_messages: List[Dict[str, Any]], cache_ttl: str = "5m", @@ -92,11 +126,17 @@ def apply_anthropic_cache_control( messages, all at the same TTL. Returns: - Deep copy of messages with cache_control breakpoints injected. + Copy of messages with cache_control breakpoints injected. Message + containers are copied so later API-retry recovery can mutate the + returned request copy without touching canonical history. """ - messages = copy.deepcopy(api_messages) - if not messages: - return messages + if not api_messages: + return [] + + messages = [ + _copy_message_for_api_mutation(msg) if isinstance(msg, dict) else msg + for msg in api_messages + ] marker = _build_marker(cache_ttl) diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index ff2cdcbaee69..b20445be90a7 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -9,7 +9,6 @@ which has provider-specific conditionals for max_tokens defaults, reasoning configuration, temperature handling, and extra_body assembly. """ -import copy from typing import Any, Dict from agent.lmstudio_reasoning import resolve_lmstudio_effort @@ -100,7 +99,7 @@ def _is_gemini_openai_compat_base_url(base_url: Any) -> bool: def _model_consumes_thought_signature(model: Any) -> bool: - """True when the outgoing model is a Gemini family model that requires + """True when the outgoing model is a Gemini model that requires ``extra_content`` (thought_signature) to be replayed on tool calls. Gemini 3 thinking models attach ``extra_content`` to each tool call and @@ -112,7 +111,7 @@ def _model_consumes_thought_signature(model: Any) -> bool: ``extra_content`` from earlier in a mixed-provider session. """ m = str(model or "").lower() - return "gemini" in m or "gemma" in m + return "gemini" in m class ChatCompletionsTransport(ProviderTransport): @@ -136,7 +135,7 @@ class ChatCompletionsTransport(ProviderTransport): ``codex_message_items`` on the message, ``call_id`` / ``response_item_id`` on ``tool_calls`` entries. - ``extra_content`` on ``tool_calls`` (Gemini thought_signature) — - stripped unless the outgoing ``model`` is itself Gemini-family. + stripped unless the outgoing ``model`` is itself Gemini. Gemini 3 thinking models attach it for replay, but strict providers (Fireworks, Mistral) reject any payload containing it with ``Extra inputs are not permitted, field: 'messages[N].tool_calls[M].extra_content'``. @@ -195,27 +194,63 @@ class ChatCompletionsTransport(ProviderTransport): if not needs_sanitize: return messages - sanitized = copy.deepcopy(messages) - for msg in sanitized: + sanitized = list(messages) + for msg_idx, msg in enumerate(messages): if not isinstance(msg, dict): continue - msg.pop("codex_reasoning_items", None) - msg.pop("codex_message_items", None) - msg.pop("tool_name", None) - msg.pop("timestamp", None) # #47868 — leak into strict providers + + copied_msg: dict[str, Any] | None = None + + def mutable_msg() -> dict[str, Any]: + nonlocal copied_msg + if copied_msg is None: + copied_msg = dict(msg) + sanitized[msg_idx] = copied_msg + return copied_msg + + if ( + "codex_reasoning_items" in msg + or "codex_message_items" in msg + or "tool_name" in msg + or "timestamp" in msg # #47868 — leak into strict providers + ): + out_msg = mutable_msg() + out_msg.pop("codex_reasoning_items", None) + out_msg.pop("codex_message_items", None) + out_msg.pop("tool_name", None) + out_msg.pop("timestamp", None) # #47868 — leak into strict providers + + # Drop all Hermes-internal scaffolding markers (``_``-prefixed). # OpenAI's message schema has no ``_``-prefixed fields, so this # is safe and future-proofs against new markers being added. - for key in [k for k in msg if isinstance(k, str) and k.startswith("_")]: - msg.pop(key, None) + internal_keys = [k for k in msg if isinstance(k, str) and k.startswith("_")] + if internal_keys: + out_msg = mutable_msg() + for key in internal_keys: + out_msg.pop(key, None) + tool_calls = msg.get("tool_calls") if isinstance(tool_calls, list): - for tc in tool_calls: + copied_tool_calls: list[Any] | None = None + for tc_idx, tc in enumerate(tool_calls): if isinstance(tc, dict): - tc.pop("call_id", None) - tc.pop("response_item_id", None) - if strip_extra_content: - tc.pop("extra_content", None) + should_copy_tc = ( + "call_id" in tc + or "response_item_id" in tc + or (strip_extra_content and "extra_content" in tc) + ) + if should_copy_tc: + if copied_tool_calls is None: + copied_tool_calls = list(tool_calls) + copied_tc = dict(tc) + copied_tc.pop("call_id", None) + copied_tc.pop("response_item_id", None) + if strip_extra_content: + copied_tc.pop("extra_content", None) + copied_tool_calls[tc_idx] = copied_tc + if copied_tool_calls is not None: + mutable_msg()["tool_calls"] = copied_tool_calls return sanitized def convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]: diff --git a/plugins/model-providers/qwen-oauth/__init__.py b/plugins/model-providers/qwen-oauth/__init__.py index a6ba29f76cbc..ddf9120a65db 100644 --- a/plugins/model-providers/qwen-oauth/__init__.py +++ b/plugins/model-providers/qwen-oauth/__init__.py @@ -1,6 +1,4 @@ """Qwen Portal provider profile.""" - -import copy from typing import Any from providers import register_provider @@ -10,6 +8,15 @@ from providers.base import ProviderProfile class QwenProfile(ProviderProfile): """Qwen Portal — message normalization, vl_high_resolution, metadata top-level.""" + @staticmethod + def _copy_part_if_request_mutable(part: dict[str, Any]) -> tuple[dict[str, Any], bool]: + image_url = part.get("image_url") + if isinstance(image_url, dict): + copied = dict(part) + copied["image_url"] = dict(image_url) + return copied, True + return part, False + def prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: """Normalize content to list-of-dicts format. @@ -17,37 +24,56 @@ class QwenProfile(ProviderProfile): Matches the behavior of run_agent.py:_qwen_prepare_chat_messages(). """ - prepared = copy.deepcopy(messages) - if not prepared: - return prepared + if not messages: + return [] - for msg in prepared: + prepared = list(messages) + system_idx: int | None = None + + for idx, msg in enumerate(messages): if not isinstance(msg, dict): continue + if system_idx is None and msg.get("role") == "system": + system_idx = idx content = msg.get("content") if isinstance(content, str): - msg["content"] = [{"type": "text", "text": content}] + msg_copy = dict(msg) + msg_copy["content"] = [{"type": "text", "text": content}] + prepared[idx] = msg_copy elif isinstance(content, list): normalized_parts = [] + changed = False for part in content: if isinstance(part, str): normalized_parts.append({"type": "text", "text": part}) + changed = True elif isinstance(part, dict): - normalized_parts.append(part) - if normalized_parts: - msg["content"] = normalized_parts + normalized_part, copied = self._copy_part_if_request_mutable(part) + normalized_parts.append(normalized_part) + changed = changed or copied + else: + changed = True + if normalized_parts and changed: + msg_copy = dict(msg) + msg_copy["content"] = normalized_parts + prepared[idx] = msg_copy # Inject cache_control on the last part of the system message. - for msg in prepared: - if isinstance(msg, dict) and msg.get("role") == "system": + if system_idx is not None: + msg = prepared[system_idx] + if isinstance(msg, dict): content = msg.get("content") if ( isinstance(content, list) and content and isinstance(content[-1], dict) ): - content[-1]["cache_control"] = {"type": "ephemeral"} - break + msg_copy = dict(msg) + content_copy = list(content) + content_copy[-1] = dict(content_copy[-1]) + content_copy[-1]["cache_control"] = {"type": "ephemeral"} + msg_copy["content"] = content_copy + prepared[system_idx] = msg_copy return prepared diff --git a/tests/agent/test_prompt_caching.py b/tests/agent/test_prompt_caching.py index 7a1c0495e1b3..889b454d4e6d 100644 --- a/tests/agent/test_prompt_caching.py +++ b/tests/agent/test_prompt_caching.py @@ -131,6 +131,54 @@ class TestApplyAnthropicCacheControl: # Original should be unmodified assert "cache_control" not in msgs[0].get("content", "") + def test_request_copy_protects_unmarked_nested_content(self): + msgs = [ + {"role": "system", "content": "System"}, + {"role": "user", "content": [{"type": "text", "text": "msg1"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "msg2"}]}, + {"role": "user", "content": [{"type": "text", "text": "msg3"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "msg4"}]}, + ] + + result = apply_anthropic_cache_control(msgs) + + assert result is not msgs + assert result[0] is not msgs[0] + assert result[1] is not msgs[1] + assert result[1]["content"] is not msgs[1]["content"] + assert result[1]["content"][0] is not msgs[1]["content"][0] + assert result[2] is not msgs[2] + assert result[2]["content"] is not msgs[2]["content"] + assert result[2]["content"][0] is not msgs[2]["content"][0] + assert "cache_control" not in msgs[2]["content"][0] + assert result[2]["content"][0]["cache_control"] == MARKER + + result[1]["content"][0]["text"] = "mutated request copy" + assert msgs[1]["content"][0]["text"] == "msg1" + + def test_request_copy_protects_nested_image_url_retry_mutation(self): + image_url = {"url": "data:image/png;base64,original"} + msgs = [ + {"role": "system", "content": "System"}, + { + "role": "user", + "content": [ + {"type": "text", "text": "see image"}, + {"type": "image_url", "image_url": image_url}, + ], + }, + ] + + result = apply_anthropic_cache_control(msgs) + + assert result[1]["content"][1] is not msgs[1]["content"][1] + assert result[1]["content"][1]["image_url"] is not image_url + + result[1]["content"][1]["image_url"]["url"] = "data:image/png;base64,shrunk" + assert msgs[1]["content"][1]["image_url"]["url"] == ( + "data:image/png;base64,original" + ) + def test_system_message_gets_marker(self): msgs = [ {"role": "system", "content": "You are helpful"}, diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index af24400ff514..7d1b07d2d756 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -77,13 +77,23 @@ class TestChatCompletionsBasic: every turn — stripping it would 400. Keep extra_content for Gemini targets (including aggregator slugs like google/gemini-3-pro). """ - for model in ("gemini-3-pro", "google/gemini-3-pro-preview", "gemma-3-27b"): + for model in ("gemini-3-pro", "google/gemini-3-pro-preview"): msgs = self._msg_with_extra_content() result = transport.convert_messages(msgs, model=model) assert result[0]["tool_calls"][0]["extra_content"] == { "google": {"thought_signature": "SIG_123"} }, model + def test_convert_messages_strips_extra_content_for_gemma(self, transport): + """Gemma can be served by Google, but it is not Gemini and rejects + Gemini-only request fields. Stale thought signatures from a prior + Gemini turn must be stripped when switching to Gemma. + """ + msgs = self._msg_with_extra_content() + result = transport.convert_messages(msgs, model="google/gemma-4-31b-it") + assert "extra_content" not in result[0]["tool_calls"][0] + assert "extra_content" in msgs[0]["tool_calls"][0] + def test_convert_messages_strips_tool_name(self, transport): """Internal `tool_name` (used for FTS indexing in the SQLite store) is not part of the OpenAI Chat Completions schema. Strict providers like @@ -161,6 +171,78 @@ class TestChatCompletionsBasic: ] assert transport.convert_messages(msgs) is msgs + def test_convert_messages_copy_on_write_for_dirty_history(self, transport): + """Dirty provider metadata should not force a full-history deepcopy.""" + clean_tool_call = { + "id": "call_clean", + "type": "function", + "function": {"name": "safe", "arguments": "{}"}, + } + msgs = [ + {"role": "user", "content": "hi", "metadata": {"large": ["shared"]}}, + { + "role": "assistant", + "content": "ok", + "tool_calls": [ + clean_tool_call, + { + "id": "call_dirty", + "call_id": "call_dirty", + "response_item_id": "fc_dirty", + "extra_content": {"google": {"thought_signature": "SIG"}}, + "type": "function", + "function": {"name": "t", "arguments": "{}"}, + }, + ], + }, + ] + + result = transport.convert_messages(msgs, model="gpt-4o") + + assert result is not msgs + assert result[0] is msgs[0] + assert result[1] is not msgs[1] + assert result[1]["tool_calls"] is not msgs[1]["tool_calls"] + assert result[1]["tool_calls"][0] is clean_tool_call + assert result[1]["tool_calls"][1] is not msgs[1]["tool_calls"][1] + assert "call_id" not in result[1]["tool_calls"][1] + assert "response_item_id" not in result[1]["tool_calls"][1] + assert "extra_content" not in result[1]["tool_calls"][1] + assert "call_id" in msgs[1]["tool_calls"][1] + assert "extra_content" in msgs[1]["tool_calls"][1] + + def test_same_history_survives_strict_then_gemini_model_switch(self, transport): + """Strict cleanup must not remove Gemini replay metadata from history.""" + msgs = [ + { + "role": "assistant", + "content": "ok", + "tool_calls": [ + { + "id": "call_1", + "call_id": "call_1", + "response_item_id": "fc_1", + "extra_content": {"google": {"thought_signature": "SIG_123"}}, + "type": "function", + "function": {"name": "t", "arguments": "{}"}, + } + ], + } + ] + + strict = transport.convert_messages(msgs, model="accounts/fireworks/models/llama") + gemini = transport.convert_messages(msgs, model="google/gemini-3-pro") + + assert "extra_content" not in strict[0]["tool_calls"][0] + assert "call_id" not in strict[0]["tool_calls"][0] + assert "response_item_id" not in strict[0]["tool_calls"][0] + assert gemini[0]["tool_calls"][0]["extra_content"] == { + "google": {"thought_signature": "SIG_123"} + } + # The canonical history still has both provider-specific metadata sets. + assert msgs[0]["tool_calls"][0]["call_id"] == "call_1" + assert msgs[0]["tool_calls"][0]["extra_content"]["google"]["thought_signature"] == "SIG_123" + class TestChatCompletionsBuildKwargs: diff --git a/tests/providers/test_provider_profiles.py b/tests/providers/test_provider_profiles.py index 3eb234030483..5f2996d8ebf8 100644 --- a/tests/providers/test_provider_profiles.py +++ b/tests/providers/test_provider_profiles.py @@ -464,6 +464,69 @@ class TestQwenProfile: assert isinstance(result[1]["content"], list) assert result[1]["content"][0]["text"] == "hello" + def test_prepare_messages_copy_on_write(self): + p = get_provider_profile("qwen-oauth") + system_part = {"type": "text", "text": "Be helpful"} + msgs = [ + {"role": "system", "content": [system_part]}, + {"role": "assistant", "content": [{"type": "text", "text": "unchanged"}]}, + {"role": "user", "content": ["hello"]}, + ] + + result = p.prepare_messages(msgs) + + assert result is not msgs + assert result[0] is not msgs[0] + assert result[0]["content"] is not msgs[0]["content"] + assert result[0]["content"][0] is not system_part + assert result[0]["content"][0]["cache_control"] == {"type": "ephemeral"} + assert "cache_control" not in system_part + assert result[1] is msgs[1] + assert result[2] is not msgs[2] + assert result[2]["content"] == [{"type": "text", "text": "hello"}] + assert msgs[2]["content"] == ["hello"] + + def test_prepare_messages_does_not_poison_strict_provider_history(self): + qwen = get_provider_profile("qwen-oauth") + msgs = [ + {"role": "system", "content": [{"type": "text", "text": "Be helpful"}]}, + {"role": "user", "content": "hello"}, + ] + + qwen_result = qwen.prepare_messages(msgs) + + assert qwen_result[0]["content"][0]["cache_control"] == {"type": "ephemeral"} + assert "cache_control" not in msgs[0]["content"][0] + assert msgs[1]["content"] == "hello" + + def test_prepare_messages_protects_nested_image_url_retry_mutation(self): + qwen = get_provider_profile("qwen-oauth") + image_url = {"url": "data:image/png;base64,original"} + msgs = [ + {"role": "system", "content": "Be helpful"}, + { + "role": "user", + "content": [ + {"type": "text", "text": "see image"}, + {"type": "image_url", "image_url": image_url}, + ], + }, + ] + + qwen_result = qwen.prepare_messages(msgs) + + assert qwen_result[1] is not msgs[1] + assert qwen_result[1]["content"] is not msgs[1]["content"] + assert qwen_result[1]["content"][1] is not msgs[1]["content"][1] + assert qwen_result[1]["content"][1]["image_url"] is not image_url + + qwen_result[1]["content"][1]["image_url"]["url"] = ( + "data:image/png;base64,shrunk" + ) + assert msgs[1]["content"][1]["image_url"]["url"] == ( + "data:image/png;base64,original" + ) + def test_metadata_top_level(self): p = get_provider_profile("qwen-oauth") meta = {"sessionId": "s123", "promptId": "p456"} diff --git a/tests/run_agent/test_provider_parity.py b/tests/run_agent/test_provider_parity.py index 8229b0f020d9..df72bec8c007 100644 --- a/tests/run_agent/test_provider_parity.py +++ b/tests/run_agent/test_provider_parity.py @@ -297,6 +297,16 @@ class TestBuildApiKwargsOpenRouter: # call_id/response_item_id still stripped regardless of model assert "call_id" not in result["tool_calls"][0] + def test_sanitize_tool_calls_strips_extra_content_for_gemma(self, monkeypatch): + """Gemma is served by Google but does not consume Gemini thought signatures.""" + agent = _make_agent(monkeypatch, "openrouter") + api_msg = self._api_msg_with_extra_content() + result = agent._sanitize_tool_calls_for_strict_api( + api_msg, model="google/gemma-4-31b-it" + ) + assert "extra_content" not in result["tool_calls"][0] + assert "call_id" not in result["tool_calls"][0] + class TestDeveloperRoleSwap: """GPT-5 and Codex models should get 'developer' instead of 'system' role.""" From 63ddd022a203e48ba0e4617f41c2661f3415e69e Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:14:15 +0530 Subject: [PATCH 303/610] refactor(salvage): scope #40632 to the two live copy-on-write sites Trim the salvaged commit to its two still-valid conversions: - ChatCompletionsTransport.convert_messages (copy-on-write sanitize) - QwenProfile.prepare_messages (copy-on-write normalize + cache_control) Dropped from the original PR: - agent/prompt_caching.py selective-copy: superseded by #57229 which already rewrites apply_anthropic_cache_control on current main. - Gemma extra_content narrowing (_model_consumes_thought_signature 'gemini or gemma' -> 'gemini' only) + its two tests: unrelated behavior change reverting deliberate e8c3ac2f5; belongs in its own PR with its own justification if pursued. Conflict resolution: preserved main's newer timestamp-stripping (#47868) inside the copy-on-write path. --- agent/prompt_caching.py | 50 ++----------------- agent/transports/chat_completions.py | 6 +-- tests/agent/test_prompt_caching.py | 48 ------------------ .../agent/transports/test_chat_completions.py | 10 ---- tests/run_agent/test_provider_parity.py | 10 ---- 5 files changed, 8 insertions(+), 116 deletions(-) diff --git a/agent/prompt_caching.py b/agent/prompt_caching.py index 34d34143127f..9a2fdf4ccce6 100644 --- a/agent/prompt_caching.py +++ b/agent/prompt_caching.py @@ -8,6 +8,7 @@ single session. Pure functions -- no class state, no AIAgent dependency. """ +import copy from typing import Any, Dict, List @@ -80,41 +81,6 @@ def _build_marker(ttl: str) -> Dict[str, str]: return marker -def _copy_message_for_api_mutation(msg: Dict[str, Any]) -> Dict[str, Any]: - """Copy message containers that later API-retry recovery may mutate.""" - copied = dict(msg) - - content = msg.get("content") - if isinstance(content, list) and content: - copied_content = [] - for part in content: - if isinstance(part, dict): - copied_part = dict(part) - image_url = part.get("image_url") - if isinstance(image_url, dict): - copied_part["image_url"] = dict(image_url) - copied_content.append(copied_part) - else: - copied_content.append(part) - copied["content"] = copied_content - - tool_calls = msg.get("tool_calls") - if isinstance(tool_calls, list) and tool_calls: - copied_tool_calls = [] - for tool_call in tool_calls: - if isinstance(tool_call, dict): - copied_tool_call = dict(tool_call) - function = tool_call.get("function") - if isinstance(function, dict): - copied_tool_call["function"] = dict(function) - copied_tool_calls.append(copied_tool_call) - else: - copied_tool_calls.append(tool_call) - copied["tool_calls"] = copied_tool_calls - - return copied - - def apply_anthropic_cache_control( api_messages: List[Dict[str, Any]], cache_ttl: str = "5m", @@ -126,17 +92,11 @@ def apply_anthropic_cache_control( messages, all at the same TTL. Returns: - Copy of messages with cache_control breakpoints injected. Message - containers are copied so later API-retry recovery can mutate the - returned request copy without touching canonical history. + Deep copy of messages with cache_control breakpoints injected. """ - if not api_messages: - return [] - - messages = [ - _copy_message_for_api_mutation(msg) if isinstance(msg, dict) else msg - for msg in api_messages - ] + messages = copy.deepcopy(api_messages) + if not messages: + return messages marker = _build_marker(cache_ttl) diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index b20445be90a7..c768968a0d6b 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -99,7 +99,7 @@ def _is_gemini_openai_compat_base_url(base_url: Any) -> bool: def _model_consumes_thought_signature(model: Any) -> bool: - """True when the outgoing model is a Gemini model that requires + """True when the outgoing model is a Gemini family model that requires ``extra_content`` (thought_signature) to be replayed on tool calls. Gemini 3 thinking models attach ``extra_content`` to each tool call and @@ -111,7 +111,7 @@ def _model_consumes_thought_signature(model: Any) -> bool: ``extra_content`` from earlier in a mixed-provider session. """ m = str(model or "").lower() - return "gemini" in m + return "gemini" in m or "gemma" in m class ChatCompletionsTransport(ProviderTransport): @@ -135,7 +135,7 @@ class ChatCompletionsTransport(ProviderTransport): ``codex_message_items`` on the message, ``call_id`` / ``response_item_id`` on ``tool_calls`` entries. - ``extra_content`` on ``tool_calls`` (Gemini thought_signature) — - stripped unless the outgoing ``model`` is itself Gemini. + stripped unless the outgoing ``model`` is itself Gemini-family. Gemini 3 thinking models attach it for replay, but strict providers (Fireworks, Mistral) reject any payload containing it with ``Extra inputs are not permitted, field: 'messages[N].tool_calls[M].extra_content'``. diff --git a/tests/agent/test_prompt_caching.py b/tests/agent/test_prompt_caching.py index 889b454d4e6d..7a1c0495e1b3 100644 --- a/tests/agent/test_prompt_caching.py +++ b/tests/agent/test_prompt_caching.py @@ -131,54 +131,6 @@ class TestApplyAnthropicCacheControl: # Original should be unmodified assert "cache_control" not in msgs[0].get("content", "") - def test_request_copy_protects_unmarked_nested_content(self): - msgs = [ - {"role": "system", "content": "System"}, - {"role": "user", "content": [{"type": "text", "text": "msg1"}]}, - {"role": "assistant", "content": [{"type": "text", "text": "msg2"}]}, - {"role": "user", "content": [{"type": "text", "text": "msg3"}]}, - {"role": "assistant", "content": [{"type": "text", "text": "msg4"}]}, - ] - - result = apply_anthropic_cache_control(msgs) - - assert result is not msgs - assert result[0] is not msgs[0] - assert result[1] is not msgs[1] - assert result[1]["content"] is not msgs[1]["content"] - assert result[1]["content"][0] is not msgs[1]["content"][0] - assert result[2] is not msgs[2] - assert result[2]["content"] is not msgs[2]["content"] - assert result[2]["content"][0] is not msgs[2]["content"][0] - assert "cache_control" not in msgs[2]["content"][0] - assert result[2]["content"][0]["cache_control"] == MARKER - - result[1]["content"][0]["text"] = "mutated request copy" - assert msgs[1]["content"][0]["text"] == "msg1" - - def test_request_copy_protects_nested_image_url_retry_mutation(self): - image_url = {"url": "data:image/png;base64,original"} - msgs = [ - {"role": "system", "content": "System"}, - { - "role": "user", - "content": [ - {"type": "text", "text": "see image"}, - {"type": "image_url", "image_url": image_url}, - ], - }, - ] - - result = apply_anthropic_cache_control(msgs) - - assert result[1]["content"][1] is not msgs[1]["content"][1] - assert result[1]["content"][1]["image_url"] is not image_url - - result[1]["content"][1]["image_url"]["url"] = "data:image/png;base64,shrunk" - assert msgs[1]["content"][1]["image_url"]["url"] == ( - "data:image/png;base64,original" - ) - def test_system_message_gets_marker(self): msgs = [ {"role": "system", "content": "You are helpful"}, diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index 7d1b07d2d756..4894c235cd4e 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -84,16 +84,6 @@ class TestChatCompletionsBasic: "google": {"thought_signature": "SIG_123"} }, model - def test_convert_messages_strips_extra_content_for_gemma(self, transport): - """Gemma can be served by Google, but it is not Gemini and rejects - Gemini-only request fields. Stale thought signatures from a prior - Gemini turn must be stripped when switching to Gemma. - """ - msgs = self._msg_with_extra_content() - result = transport.convert_messages(msgs, model="google/gemma-4-31b-it") - assert "extra_content" not in result[0]["tool_calls"][0] - assert "extra_content" in msgs[0]["tool_calls"][0] - def test_convert_messages_strips_tool_name(self, transport): """Internal `tool_name` (used for FTS indexing in the SQLite store) is not part of the OpenAI Chat Completions schema. Strict providers like diff --git a/tests/run_agent/test_provider_parity.py b/tests/run_agent/test_provider_parity.py index df72bec8c007..8229b0f020d9 100644 --- a/tests/run_agent/test_provider_parity.py +++ b/tests/run_agent/test_provider_parity.py @@ -297,16 +297,6 @@ class TestBuildApiKwargsOpenRouter: # call_id/response_item_id still stripped regardless of model assert "call_id" not in result["tool_calls"][0] - def test_sanitize_tool_calls_strips_extra_content_for_gemma(self, monkeypatch): - """Gemma is served by Google but does not consume Gemini thought signatures.""" - agent = _make_agent(monkeypatch, "openrouter") - api_msg = self._api_msg_with_extra_content() - result = agent._sanitize_tool_calls_for_strict_api( - api_msg, model="google/gemma-4-31b-it" - ) - assert "extra_content" not in result["tool_calls"][0] - assert "call_id" not in result["tool_calls"][0] - class TestDeveloperRoleSwap: """GPT-5 and Codex models should get 'developer' instead of 'system' role.""" From e7648d59129ab1709ed111eca3b1d5f11408adac Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:27:35 +0530 Subject: [PATCH 304/610] test: restore gemma-3-27b to keep-extra_content coverage Review finding: PR #40632's branch had silently dropped gemma-3-27b from the keep-extra_content test loop (part of its Gemma narrowing, which this salvage reverts). Restore main's original coverage so a future narrowing back to Gemini-only fails loudly. --- tests/agent/transports/test_chat_completions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index 4894c235cd4e..fee10ccec4c3 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -77,7 +77,7 @@ class TestChatCompletionsBasic: every turn — stripping it would 400. Keep extra_content for Gemini targets (including aggregator slugs like google/gemini-3-pro). """ - for model in ("gemini-3-pro", "google/gemini-3-pro-preview"): + for model in ("gemini-3-pro", "google/gemini-3-pro-preview", "gemma-3-27b"): msgs = self._msg_with_extra_content() result = transport.convert_messages(msgs, model=model) assert result[0]["tool_calls"][0]["extra_content"] == { From f5bc18f9011745b7fb0bb0e72c7b5b756ffd686f Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:48:50 +0530 Subject: [PATCH 305/610] fix: write expanded HERMES_WEB_DIST back for web_server's raw read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-2 review finding: the validation branch expanduser()s the path but web_server.py reads os.environ['HERMES_WEB_DIST'] raw at import — a '~/dist' value would validate here and still 404 there. Write the expanded path back before the web_server import. Adds a regression test asserting the env var holds the expanded path after cmd_dashboard. --- hermes_cli/main.py | 4 ++++ .../test_dashboard_web_dist_validation.py | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 47534b363882..f450a76666f3 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12092,6 +12092,10 @@ def cmd_dashboard(args): print(" Pre-build first: npm install --workspace web && npm run build -w web") print(" Or unset HERMES_WEB_DIST to build and use the default web UI dist.") sys.exit(1) + # Write the expanded path back: web_server reads HERMES_WEB_DIST raw + # at import (no expanduser), so a validated "~/dist" would otherwise + # pass here and still 404 there. + os.environ["HERMES_WEB_DIST"] = str(_dist_root) print(f"→ Using web dist from HERMES_WEB_DIST: {_dist_root}") # Discover and load plugins so any DashboardAuthProvider plugin diff --git a/tests/hermes_cli/test_dashboard_web_dist_validation.py b/tests/hermes_cli/test_dashboard_web_dist_validation.py index 92c63fe3e357..d1d7a7808408 100644 --- a/tests/hermes_cli/test_dashboard_web_dist_validation.py +++ b/tests/hermes_cli/test_dashboard_web_dist_validation.py @@ -111,3 +111,26 @@ def test_env_dist_with_index_starts_server(main_mod, monkeypatch, tmp_path): assert len(started) == 1 assert builds == [] + + +def test_env_dist_tilde_expanded_for_web_server(main_mod, monkeypatch, tmp_path): + """A '~/...' HERMES_WEB_DIST must be written back expanded so + web_server's raw os.environ read serves the validated path.""" + _wire_common(main_mod, monkeypatch) + home = tmp_path / "home" + dist = home / "mydist" + dist.mkdir(parents=True) + (dist / "index.html").write_text("<html></html>", encoding="utf-8") + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("HERMES_WEB_DIST", "~/mydist") + + monkeypatch.setitem( + sys.modules, + "hermes_cli.web_server", + types.SimpleNamespace(start_server=lambda **k: None), + ) + + main_mod.cmd_dashboard(_args()) + + import os + assert os.environ["HERMES_WEB_DIST"] == str(dist) From 1e16120603b4bc34114f87c0d278a7805241087c Mon Sep 17 00:00:00 2001 From: liuhao1024 <sunsky.lau@gmail.com> Date: Wed, 8 Jul 2026 00:46:51 +0800 Subject: [PATCH 306/610] fix(reasoning): add deepseek-v4-flash and deepseek-v4-pro to reasoning timeout floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepSeek V4 models (deepseek-v4-flash, deepseek-v4-pro) emit reasoning_content in a separate delta field before final content, requiring the same 600s stale timeout floor as R1. Without this, streams hang for 30–50s with APITimeoutError on providers like opencode-go while direct calls succeed in ~3s. Fixes #60338. --- agent/reasoning_timeouts.py | 10 +++++- tests/agent/test_reasoning_timeouts.py | 46 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/agent/test_reasoning_timeouts.py diff --git a/agent/reasoning_timeouts.py b/agent/reasoning_timeouts.py index 9e0b5cab9b91..768ce0494def 100644 --- a/agent/reasoning_timeouts.py +++ b/agent/reasoning_timeouts.py @@ -66,9 +66,13 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = ( ("nemotron-3-ultra", 600), ("nemotron-3-super", 600), ("nemotron-3-nano", 300), - # DeepSeek — R1 reasoning model on hosted NIM / DeepSeek direct. + # DeepSeek — R1 and V4 reasoning models on hosted NIM / DeepSeek direct. + # V4 series emits reasoning_content in a separate delta field before + # final content, requiring the same extended stale timeout floor. ("deepseek-r1", 600), ("deepseek-reasoner", 600), + ("deepseek-v4-flash", 600), + ("deepseek-v4-pro", 600), # Qwen — QwQ reasoning + Qwen3 thinking variants. QwQ-32B # preview is the stable slug; ``qwen3`` covers the family of # thinking-mode Qwen3 models (qwen3-235b-a22b, qwen3-32b, etc.) @@ -190,6 +194,10 @@ def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]: 300.0 >>> get_reasoning_stale_timeout_floor("deepseek/deepseek-r1") 600.0 + >>> get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-flash") + 600.0 + >>> get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-pro") + 600.0 >>> get_reasoning_stale_timeout_floor("qwen/qwen3-235b-a22b-thinking") 180.0 >>> get_reasoning_stale_timeout_floor("x-ai/grok-4-fast-reasoning") diff --git a/tests/agent/test_reasoning_timeouts.py b/tests/agent/test_reasoning_timeouts.py new file mode 100644 index 000000000000..9651387b7250 --- /dev/null +++ b/tests/agent/test_reasoning_timeouts.py @@ -0,0 +1,46 @@ +"""Test get_reasoning_stale_timeout_floor for DeepSeek V4 reasoning models.""" + +from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor + + +def test_deepseek_v4_reasoning_models_have_timeout_floor(): + """DeepSeek V4 reasoning models should have 600s timeout floor. + + DeepSeek V4 models (deepseek-v4-flash, deepseek-v4-pro) emit + reasoning_content in a separate delta field before final content, + requiring the same extended stale timeout floor as R1. + See #60338. + """ + # Direct model names (no aggregator prefix) + assert get_reasoning_stale_timeout_floor("deepseek-v4-flash") == 600.0 + assert get_reasoning_stale_timeout_floor("deepseek-v4-pro") == 600.0 + + # With aggregator prefixes (common usage) + assert get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-flash") == 600.0 + assert get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-pro") == 600.0 + + # With custom provider prefixes + assert get_reasoning_stale_timeout_floor("opencode/deepseek-v4-flash") == 600.0 + assert get_reasoning_stale_timeout_floor("custom/deepseek-v4-pro") == 600.0 + + +def test_deepseek_v4_timeout_floor_matches_r1(): + """V4 models should have the same floor as R1 (600s).""" + v4_flash_floor = get_reasoning_stale_timeout_floor("deepseek-v4-flash") + v4_pro_floor = get_reasoning_stale_timeout_floor("deepseek-v4-pro") + r1_floor = get_reasoning_stale_timeout_floor("deepseek-r1") + reasoner_floor = get_reasoning_stale_timeout_floor("deepseek-reasoner") + + assert v4_flash_floor == v4_pro_floor == r1_floor == reasoner_floor == 600.0 + + +def test_deepseek_v4_does_not_match_non_reasoning_deepseek(): + """Non-reasoning deepseek variants should not match V4 patterns.""" + # deepseek-chat is not a reasoning model and should not match + # the deepseek-v4 patterns (requires start-of-slug anchor). + assert get_reasoning_stale_timeout_floor("deepseek-chat") is None + assert get_reasoning_stale_timeout_floor("deepseek/deepseek-chat") is None + + # But V4 patterns should still match V4 models even with non-V4 prefixes + assert get_reasoning_stale_timeout_floor("some-deepseek-v4-flash") is None # wrong prefix + assert get_reasoning_stale_timeout_floor("deepseek-v4-flash-model") == 600.0 # suffix OK From 411d59976410bc6eabcd4d59b6f688de3e879f05 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:58:19 +0530 Subject: [PATCH 307/610] test: fold deepseek-v4 cases into canonical reasoning-floor test, drop duplicate file The salvaged PR added a standalone test_reasoning_timeouts.py that duplicated the structure of the existing parametrized test_reasoning_stale_timeout_floor.py. Fold the v4-flash/v4-pro/-free positive cases and deepseek-chat negative cases into the canonical parametrized tables and remove the redundant file. --- .../test_reasoning_stale_timeout_floor.py | 11 ++++- tests/agent/test_reasoning_timeouts.py | 46 ------------------- 2 files changed, 10 insertions(+), 47 deletions(-) delete mode 100644 tests/agent/test_reasoning_timeouts.py diff --git a/tests/agent/test_reasoning_stale_timeout_floor.py b/tests/agent/test_reasoning_stale_timeout_floor.py index 3177b585ab49..d17ed4d01352 100644 --- a/tests/agent/test_reasoning_stale_timeout_floor.py +++ b/tests/agent/test_reasoning_stale_timeout_floor.py @@ -46,10 +46,15 @@ import pytest ("nvidia/nemotron-3-ultra-550b-a55b", 600.0), ("nvidia/nemotron-3-super-120b-a12b", 600.0), ("nvidia/nemotron-3-nano-30b-a3b", 300.0), - # DeepSeek R1 + DeepSeek reasoner. + # DeepSeek R1 + DeepSeek reasoner + V4 reasoning series. + # V4 emits reasoning_content in a separate delta before final + # content (same shape as R1), so it needs the same 600s floor. ("deepseek/deepseek-r1", 600.0), ("deepseek/deepseek-r1-distill-llama-70b", 600.0), ("deepseek/deepseek-reasoner", 600.0), + ("deepseek/deepseek-v4-flash", 600.0), + ("deepseek/deepseek-v4-pro", 600.0), + ("deepseek-v4-flash-free", 600.0), # catalog -free variant inherits via separator anchor # Qwen QwQ + Qwen3 thinking variants (qwen3 family entry matches all). ("qwen/qwq-32b-preview", 300.0), ("qwen/qwen3-235b-a22b-thinking", 180.0), @@ -104,6 +109,10 @@ def test_reasoning_stale_timeout_floor_positive_cases(model, expected): "x-ai/grok-code-fast-1", # Qwen2 must not match Qwen3 (different family). "qwen2-72b-instruct", + # Non-reasoning DeepSeek chat must not match the v4 reasoning entries. + "deepseek-chat", + "deepseek/deepseek-chat", + "some-deepseek-v4-flash", # embedded v4 slug, NOT start of slug # Empty / None / non-string inputs — must return None, not raise. "", None, diff --git a/tests/agent/test_reasoning_timeouts.py b/tests/agent/test_reasoning_timeouts.py deleted file mode 100644 index 9651387b7250..000000000000 --- a/tests/agent/test_reasoning_timeouts.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Test get_reasoning_stale_timeout_floor for DeepSeek V4 reasoning models.""" - -from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor - - -def test_deepseek_v4_reasoning_models_have_timeout_floor(): - """DeepSeek V4 reasoning models should have 600s timeout floor. - - DeepSeek V4 models (deepseek-v4-flash, deepseek-v4-pro) emit - reasoning_content in a separate delta field before final content, - requiring the same extended stale timeout floor as R1. - See #60338. - """ - # Direct model names (no aggregator prefix) - assert get_reasoning_stale_timeout_floor("deepseek-v4-flash") == 600.0 - assert get_reasoning_stale_timeout_floor("deepseek-v4-pro") == 600.0 - - # With aggregator prefixes (common usage) - assert get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-flash") == 600.0 - assert get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-pro") == 600.0 - - # With custom provider prefixes - assert get_reasoning_stale_timeout_floor("opencode/deepseek-v4-flash") == 600.0 - assert get_reasoning_stale_timeout_floor("custom/deepseek-v4-pro") == 600.0 - - -def test_deepseek_v4_timeout_floor_matches_r1(): - """V4 models should have the same floor as R1 (600s).""" - v4_flash_floor = get_reasoning_stale_timeout_floor("deepseek-v4-flash") - v4_pro_floor = get_reasoning_stale_timeout_floor("deepseek-v4-pro") - r1_floor = get_reasoning_stale_timeout_floor("deepseek-r1") - reasoner_floor = get_reasoning_stale_timeout_floor("deepseek-reasoner") - - assert v4_flash_floor == v4_pro_floor == r1_floor == reasoner_floor == 600.0 - - -def test_deepseek_v4_does_not_match_non_reasoning_deepseek(): - """Non-reasoning deepseek variants should not match V4 patterns.""" - # deepseek-chat is not a reasoning model and should not match - # the deepseek-v4 patterns (requires start-of-slug anchor). - assert get_reasoning_stale_timeout_floor("deepseek-chat") is None - assert get_reasoning_stale_timeout_floor("deepseek/deepseek-chat") is None - - # But V4 patterns should still match V4 models even with non-V4 prefixes - assert get_reasoning_stale_timeout_floor("some-deepseek-v4-flash") is None # wrong prefix - assert get_reasoning_stale_timeout_floor("deepseek-v4-flash-model") == 600.0 # suffix OK From 5a4249146fc49bc27d31b227c0dbfd7f26c97a78 Mon Sep 17 00:00:00 2001 From: nankingjing <1079826437@qq.com> Date: Mon, 6 Jul 2026 10:06:02 +0800 Subject: [PATCH 308/610] perf(skills): cache skill discovery results by directory mtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _find_all_skills() re-reads every SKILL.md on every call, which is wasteful when nothing changed between turns. Cache results keyed by the max mtime across all scanned skill directories — a skill write touches the directory, bumping mtime past the cached value and triggering an automatic re-scan. skip_disabled True/False are cached separately. This commit is unstacked from #58984; it carries only the skill discovery cache change. (cherry picked from commit cd65673a8fddfec8a0fa130197d49aaab1fefc77) --- tools/skills_tool.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 86a8a5de6cb7..a9087232c4d6 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -86,6 +86,15 @@ from agent.skill_utils import ( logger = logging.getLogger(__name__) +# Per-session skill discovery cache. _find_all_skills() re-reads every +# SKILL.md on every call; with hundreds of skills this is wasteful. +# We cache by the max mtime across all scanned skill directories so a +# write by skill_manage (which touches the directory) invalidates the +# cache automatically. skip_disabled True/False are cached separately. +_SKILLS_CACHE: dict = {} # {cache_key: (max_mtime, skills_list)} +_SKILLS_CACHE_KEY_DISABLED = "with_disabled" +_SKILLS_CACHE_KEY_FILTERED = "filtered" + # All skills live in ~/.hermes/skills/ (seeded from bundled skills/ on install). # This is the single source of truth -- agent edits, hub installs, and bundled @@ -627,9 +636,38 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: Returns: List of skill metadata dicts (name, description, category). + + Results are cached per-session; the cache is invalidated when any + scanned skill directory's mtime changes (a skill write touches the + directory, triggering a re-scan on the next call). """ from agent.skill_utils import get_external_skills_dirs, iter_skill_index_files + cache_key = _SKILLS_CACHE_KEY_DISABLED if skip_disabled else _SKILLS_CACHE_KEY_FILTERED + + # Collect directories to scan (same set as the scan loop below). + dirs_to_scan: list = [] + if SKILLS_DIR.exists(): + dirs_to_scan.append(SKILLS_DIR) + dirs_to_scan.extend(get_external_skills_dirs()) + + # Compute the freshest mtime across all scanned directories. + max_mtime = 0.0 + for d in dirs_to_scan: + try: + st = d.stat() + if st.st_mtime > max_mtime: + max_mtime = st.st_mtime + except OSError: + continue + + # Serve from cache when nothing changed since last scan. + cached = _SKILLS_CACHE.get(cache_key) + if cached is not None: + cached_mtime, cached_skills = cached + if cached_mtime >= max_mtime: + return cached_skills + skills = [] seen_names: set = set() @@ -695,6 +733,10 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: ) continue + # Store in cache keyed by the freshest directory mtime seen during + # this scan. Any subsequent skill write that touches a directory + # will bump the mtime past the cached value and trigger a re-scan. + _SKILLS_CACHE[cache_key] = (max_mtime, skills) return skills From 9e9608ecc3047ae68f26eef0c1c826f00e9212d2 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:22:07 +0530 Subject: [PATCH 309/610] fix: harden skill-discovery cache signature + TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the cherry-picked cache (follow-up to #58985): - The cache key was the max mtime of only the TOP-LEVEL scan dirs. Adding/removing a skill inside a category subdir bumps the category dir's mtime, NOT the root's, so the cache served a stale list indefinitely. Replace with a per-dir signature covering roots + immediate children (one scandir per dir; mirrors hermes_cli/profiles.py::_count_skills from d5eee133e). - The disabled-set is config-driven and changes with no filesystem mtime bump; fold it into the signature so /skills disable takes effect without a restart. - Platform is part of the signature (gateway processes serve multiple platform scopes; scan results are platform-filtered). - Add a 30s TTL to bound staleness from in-place SKILL.md edits (file mtime is invisible to any directory signature). - The original also keyed dirs off the module-level SKILLS_DIR constant; the scan itself uses _skills_dir() (live profile HERMES_HOME) — use the same resolution for the signature. Mutation-verified: nested-add, disabled-set, and TTL tests fail against the pre-fix cache and pass with it. --- .../tools/test_skills_tool_discovery_cache.py | 121 ++++++++++++++++++ tools/skills_tool.py | 113 ++++++++++------ 2 files changed, 194 insertions(+), 40 deletions(-) create mode 100644 tests/tools/test_skills_tool_discovery_cache.py diff --git a/tests/tools/test_skills_tool_discovery_cache.py b/tests/tools/test_skills_tool_discovery_cache.py new file mode 100644 index 000000000000..b262077d4791 --- /dev/null +++ b/tests/tools/test_skills_tool_discovery_cache.py @@ -0,0 +1,121 @@ +"""Regression tests for the _find_all_skills discovery cache (#58985 salvage). + +Covers the cache-signature fix layered on the cherry-picked contributor +commit: the original keyed the cache on the max mtime of only the TOP-LEVEL +scan dirs, so adding/removing a skill inside a category subdir (which bumps +the category dir's mtime, not the root's) served a stale list indefinitely. +The signature now covers roots + immediate children (mirroring +hermes_cli/profiles.py::_count_skills) plus the disabled-set, with a short +TTL bounding in-place SKILL.md edit staleness. +""" + +import time + +import pytest + +import tools.skills_tool as st + + +@pytest.fixture(autouse=True) +def _fresh_cache(monkeypatch, tmp_path): + """Isolate every test: clear the module cache and point the scan at + an empty external-dirs list + a tmp skills root.""" + st._SKILLS_CACHE.clear() + monkeypatch.setattr(st, "_skills_dir", lambda: tmp_path / "skills") + monkeypatch.setattr( + "agent.skill_utils.get_external_skills_dirs", lambda: [] + ) + monkeypatch.setattr(st, "_get_disabled_skill_names", lambda: set()) + yield + st._SKILLS_CACHE.clear() + + +def _write_skill(root, category, name, description="a skill"): + d = root / "skills" / category / name + d.mkdir(parents=True, exist_ok=True) + (d / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: {description}\n---\n# {name}\n", + encoding="utf-8", + ) + return d + + +def test_cache_hit_serves_same_result_without_rescan(tmp_path): + _write_skill(tmp_path, "cat-a", "skill-one") + first = st._find_all_skills() + assert [s["name"] for s in first] == ["skill-one"] + + second = st._find_all_skills() + assert second is first # cache hit returns the SAME object, no rescan + + +def test_nested_category_skill_add_invalidates(tmp_path): + """THE bug in the original PR: a new skill inside an existing category + bumps the category dir's mtime only — the root-mtime key missed it.""" + _write_skill(tmp_path, "cat-a", "skill-one") + first = st._find_all_skills() + assert [s["name"] for s in first] == ["skill-one"] + + # Freeze the ROOT dir's mtime so only the category-child signature moves + # (guards against filesystems bumping the parent too). + root = tmp_path / "skills" + root_stat = root.stat() + _write_skill(tmp_path, "cat-a", "skill-two") + import os + os.utime(root, (root_stat.st_atime, root_stat.st_mtime)) + + names = sorted(s["name"] for s in st._find_all_skills()) + assert names == ["skill-one", "skill-two"], ( + "category-nested skill add must invalidate the cache" + ) + + +def test_disabled_set_change_invalidates(tmp_path, monkeypatch): + """Disabling a skill is a config change with NO filesystem mtime bump — + it must still invalidate.""" + _write_skill(tmp_path, "cat-a", "skill-one") + _write_skill(tmp_path, "cat-a", "skill-two") + names = sorted(s["name"] for s in st._find_all_skills()) + assert names == ["skill-one", "skill-two"] + + monkeypatch.setattr(st, "_get_disabled_skill_names", lambda: {"skill-two"}) + names = sorted(s["name"] for s in st._find_all_skills()) + assert names == ["skill-one"], "disabled-set change must invalidate the cache" + + +def test_ttl_expiry_forces_rescan(tmp_path, monkeypatch): + """In-place SKILL.md edits are invisible to any directory signature; + the TTL bounds that staleness.""" + skill_dir = _write_skill(tmp_path, "cat-a", "skill-one", "old description") + first = st._find_all_skills() + assert first[0]["description"] == "old description" + + # Edit the file in place; keep every directory mtime identical. + import os + cat = tmp_path / "skills" / "cat-a" + root = tmp_path / "skills" + stats = {p: p.stat() for p in (root, cat, skill_dir)} + (skill_dir / "SKILL.md").write_text( + "---\nname: skill-one\ndescription: new description\n---\n# skill-one\n", + encoding="utf-8", + ) + for p, s in stats.items(): + os.utime(p, (s.st_atime, s.st_mtime)) + + # Within TTL: stale (documented trade-off). + assert st._find_all_skills()[0]["description"] == "old description" + + # Past TTL: fresh. + monkeypatch.setattr(st, "_SKILLS_CACHE_TTL_SECONDS", 0.0) + assert st._find_all_skills()[0]["description"] == "new description" + + +def test_disabled_and_full_views_cached_separately(tmp_path, monkeypatch): + _write_skill(tmp_path, "cat-a", "skill-one") + _write_skill(tmp_path, "cat-a", "skill-two") + monkeypatch.setattr(st, "_get_disabled_skill_names", lambda: {"skill-two"}) + + filtered = sorted(s["name"] for s in st._find_all_skills()) + everything = sorted(s["name"] for s in st._find_all_skills(skip_disabled=True)) + assert filtered == ["skill-one"] + assert everything == ["skill-one", "skill-two"] diff --git a/tools/skills_tool.py b/tools/skills_tool.py index a9087232c4d6..ae063e38e0e0 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -68,6 +68,7 @@ Usage: import json import logging +import time from hermes_constants import get_hermes_home, display_hermes_home import os @@ -88,14 +89,53 @@ logger = logging.getLogger(__name__) # Per-session skill discovery cache. _find_all_skills() re-reads every # SKILL.md on every call; with hundreds of skills this is wasteful. -# We cache by the max mtime across all scanned skill directories so a -# write by skill_manage (which touches the directory) invalidates the -# cache automatically. skip_disabled True/False are cached separately. -_SKILLS_CACHE: dict = {} # {cache_key: (max_mtime, skills_list)} +# Cache validation (mirrors hermes_cli/profiles.py::_count_skills, d5eee133e): +# - signature = per-dir max mtime of the dir AND its immediate children +# (one scandir per dir; catches skill add/remove inside categories, +# which does NOT bump the root dir's mtime), plus the disabled-set +# (config-driven — changes with no filesystem mtime bump at all) +# - a short TTL bounds staleness from in-place SKILL.md edits, which +# bump only the file's mtime, invisible to any directory signature. +# skip_disabled True/False are cached separately. +_SKILLS_CACHE: dict = {} # {cache_key: (signature, timestamp, skills_list)} +_SKILLS_CACHE_TTL_SECONDS = 30.0 _SKILLS_CACHE_KEY_DISABLED = "with_disabled" _SKILLS_CACHE_KEY_FILTERED = "filtered" +def _skills_scan_signature(dirs_to_scan, disabled) -> tuple: + """Cheap change-signature for the skill scan inputs. + + O(#dirs + #categories) stat calls, not a recursive walk. Includes the + platform the scan's ``skill_matches_platform`` filter will use (read + from ``agent.skill_utils``'s ``sys`` so test patches of that module + are honored) — the scan result is platform-dependent. + """ + from agent import skill_utils as _skill_utils + + platform = getattr(getattr(_skill_utils, "sys", None), "platform", "") + sig = [] + for d in dirs_to_scan: + try: + m = d.stat().st_mtime + except OSError: + continue + try: + with os.scandir(d) as it: + for entry in it: + try: + if entry.is_dir(follow_symlinks=False): + em = entry.stat(follow_symlinks=False).st_mtime + if em > m: + m = em + except OSError: + continue + except OSError: + pass + sig.append((str(d), m)) + return (tuple(sig), frozenset(disabled), platform) + + # All skills live in ~/.hermes/skills/ (seeded from bundled skills/ on install). # This is the single source of truth -- agent edits, hub installs, and bundled # skills all coexist here without polluting the git repo. @@ -637,50 +677,43 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: Returns: List of skill metadata dicts (name, description, category). - Results are cached per-session; the cache is invalidated when any - scanned skill directory's mtime changes (a skill write touches the - directory, triggering a re-scan on the next call). + Results are cached per-session; the cache is invalidated when the scan + signature changes (dir/category mtimes or the disabled-set) and expires + after a short TTL to bound staleness from in-place SKILL.md edits. """ from agent.skill_utils import get_external_skills_dirs, iter_skill_index_files cache_key = _SKILLS_CACHE_KEY_DISABLED if skip_disabled else _SKILLS_CACHE_KEY_FILTERED - # Collect directories to scan (same set as the scan loop below). - dirs_to_scan: list = [] - if SKILLS_DIR.exists(): - dirs_to_scan.append(SKILLS_DIR) - dirs_to_scan.extend(get_external_skills_dirs()) - - # Compute the freshest mtime across all scanned directories. - max_mtime = 0.0 - for d in dirs_to_scan: - try: - st = d.stat() - if st.st_mtime > max_mtime: - max_mtime = st.st_mtime - except OSError: - continue - - # Serve from cache when nothing changed since last scan. - cached = _SKILLS_CACHE.get(cache_key) - if cached is not None: - cached_mtime, cached_skills = cached - if cached_mtime >= max_mtime: - return cached_skills - - skills = [] - seen_names: set = set() - - # Load disabled set once (not per-skill) + # Load disabled set once (not per-skill). Part of the cache signature: + # disabling a skill is a config change with no filesystem mtime bump. disabled = set() if skip_disabled else _get_disabled_skill_names() - # Scan local dir first, then external dirs (local takes precedence) - dirs_to_scan = [] + # Collect directories to scan — same resolution as the scan loop below + # (_skills_dir() resolves the LIVE profile HERMES_HOME; the module-level + # SKILLS_DIR can be stale in long-lived runtimes). + dirs_to_scan: list = [] active_skills_dir = _skills_dir() if active_skills_dir.exists(): dirs_to_scan.append(active_skills_dir) dirs_to_scan.extend(get_external_skills_dirs()) + signature = _skills_scan_signature(dirs_to_scan, disabled) + now = time.monotonic() + + cached = _SKILLS_CACHE.get(cache_key) + if ( + cached is not None + and cached[0] == signature + and (now - cached[1]) < _SKILLS_CACHE_TTL_SECONDS + ): + return cached[2] + + skills = [] + seen_names: set = set() + + # Scan local dir first, then external dirs (local takes precedence) — + # dirs_to_scan already resolved above for the signature. for scan_dir in dirs_to_scan: for skill_md in iter_skill_index_files(scan_dir, "SKILL.md"): if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts): @@ -733,10 +766,10 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: ) continue - # Store in cache keyed by the freshest directory mtime seen during - # this scan. Any subsequent skill write that touches a directory - # will bump the mtime past the cached value and trigger a re-scan. - _SKILLS_CACHE[cache_key] = (max_mtime, skills) + # Store in cache keyed by the scan signature computed BEFORE the scan + # (a write racing the scan changes the signature, so the next call + # re-scans rather than serving the torn result past the TTL). + _SKILLS_CACHE[cache_key] = (signature, now, skills) return skills From cbdf87b21fed78e0660da93e87c534929d9aa130 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:30:34 +0530 Subject: [PATCH 310/610] fix: return per-call copies from the skill-discovery cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: callers mutate the returned dicts in place — hermes_cli/web_server.py annotates s['enabled']/s['usage'] on the skills list — so handing out the cached objects poisons the cache for every subsequent caller (and is a cross-thread shared-mutable hazard in the gateway). Return [dict(s) for s in cached] on both hit and miss paths; warm-path cost is negligible (241x speedup retained on a 300-skill fixture). Regression test mutates a returned list/dict and asserts the next cached call is clean. --- tests/tools/test_skills_tool_discovery_cache.py | 12 ++++++++++-- tools/skills_tool.py | 10 +++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/tools/test_skills_tool_discovery_cache.py b/tests/tools/test_skills_tool_discovery_cache.py index b262077d4791..55908118cfa4 100644 --- a/tests/tools/test_skills_tool_discovery_cache.py +++ b/tests/tools/test_skills_tool_discovery_cache.py @@ -40,13 +40,21 @@ def _write_skill(root, category, name, description="a skill"): return d -def test_cache_hit_serves_same_result_without_rescan(tmp_path): +def test_cache_hit_serves_copies_not_cache_objects(tmp_path): + """Callers mutate the returned dicts (web_server annotates + s['enabled']/s['usage']) — the cache must hand out per-call copies.""" _write_skill(tmp_path, "cat-a", "skill-one") first = st._find_all_skills() assert [s["name"] for s in first] == ["skill-one"] + # Mutate what the first caller got; the next (cached) call must be clean. + first[0]["enabled"] = False + first.append({"name": "junk"}) + second = st._find_all_skills() - assert second is first # cache hit returns the SAME object, no rescan + assert [s["name"] for s in second] == ["skill-one"] + assert "enabled" not in second[0], "cache poisoned by caller mutation" + assert second is not first def test_nested_category_skill_add_invalidates(tmp_path): diff --git a/tools/skills_tool.py b/tools/skills_tool.py index ae063e38e0e0..a5613f62c4c8 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -707,7 +707,10 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: and cached[0] == signature and (now - cached[1]) < _SKILLS_CACHE_TTL_SECONDS ): - return cached[2] + # Per-call shallow copies: callers mutate the returned dicts + # (e.g. web_server annotates s["enabled"]/s["usage"]) — handing + # out the cached objects would poison the cache for everyone else. + return [dict(s) for s in cached[2]] skills = [] seen_names: set = set() @@ -768,9 +771,10 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: # Store in cache keyed by the scan signature computed BEFORE the scan # (a write racing the scan changes the signature, so the next call - # re-scans rather than serving the torn result past the TTL). + # re-scans rather than serving the torn result past the TTL). Same + # shallow-copy contract as the hit path — the caller may mutate. _SKILLS_CACHE[cache_key] = (signature, now, skills) - return skills + return [dict(s) for s in skills] def _sort_skills(skills: List[Dict[str, Any]]) -> List[Dict[str, Any]]: From 709da844b5b62264d423d25dd53e57f5d593634e Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:38:32 +0530 Subject: [PATCH 311/610] feat(gateway): attach MEDIA: caption to the media bubble on standalone sends hermes send "MEDIA:/x.png This Caption" now arrives as one native captioned bubble instead of a separate text message followed by an uncaptioned bubble. Root cause: the standalone senders (hermes send / cron / send_message tool) stripped the MEDIA: tag, sent the remaining text as its own message, and called the media send with no caption -- even though hermes send's help advertises the captioned form and the bridges/adapters already support a caption. Signal already captioned correctly. - tools/send_message_tool.py: new _media_caption_split() chokepoint decides caption-vs-separate-body (single captionable non-voice file within the platform's message-length cap). Wired into the Telegram, WhatsApp and Discord dispatch paths. - Telegram/WhatsApp/Discord: when the single captioned file is missing, the caption text is delivered as a plain message so it is never silently lost. - Telegram caption send gets a MarkdownV2->plain parse fallback. - Tests: _media_caption_split unit tests + per-platform caption tests (ride, multi-file fallback, voice exclusion, over-limit fallback, missing-file text fallback); updated the 3 tests that asserted the old text-then-media split. Closes the gap reported against #58911 (the MEDIA_CAPTION directive PR); credit to @ferreiraesilva for surfacing the caption behavior. --- plugins/platforms/discord/adapter.py | 31 +++- plugins/platforms/whatsapp/adapter.py | 31 +++- .../test_discord_send_message_caption.py | 133 ++++++++++++++ tests/tools/test_media_caption_split.py | 115 ++++++++++++ tests/tools/test_send_message_tool.py | 39 ++-- .../test_telegram_send_message_caption.py | 141 +++++++++++++++ .../tools/test_whatsapp_send_message_media.py | 75 ++++++++ tools/send_message_tool.py | 169 ++++++++++++++++++ 8 files changed, 716 insertions(+), 18 deletions(-) create mode 100644 tests/tools/test_discord_send_message_caption.py create mode 100644 tests/tools/test_media_caption_split.py create mode 100644 tests/tools/test_telegram_send_message_caption.py diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index ddaac7ab074a..3d4ebbfb9a57 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -7881,6 +7881,7 @@ async def _standalone_send( thread_id: Optional[str] = None, media_files: Optional[list] = None, force_document: bool = False, + caption: Optional[str] = None, ) -> Dict[str, Any]: """Send via Discord REST API without a live gateway adapter. @@ -7981,7 +7982,7 @@ async def _standalone_send( {"id": str(idx), "filename": os.path.basename(path)} for idx, path in enumerate(valid_media) ] - starter_message = {"content": message, "attachments": attachments_meta} + starter_message = {"content": (caption or message), "attachments": attachments_meta} payload_json = json.dumps({"name": thread_name, "message": starter_message}) form = aiohttp.FormData() @@ -8061,16 +8062,42 @@ async def _standalone_send( _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES, ) - # Send each media file as a separate multipart upload + # Send each media file as a separate multipart upload. When a + # MEDIA:<path> caption was supplied, ride it as the message content + # on the attachment so it appears under the media bubble instead of + # as a separate message. caption_pending tracks whether the caption + # still needs delivering, so a missing file falls back to a plain + # message rather than silently dropping the text. + caption_pending = bool(caption) for media_path, _is_voice in media_files: if not os.path.exists(media_path): warning = f"Media file not found, skipping: {media_path}" logger.warning(warning) warnings.append(warning) + if caption_pending: + try: + async with session.post( + url, headers=json_headers, + json={"content": caption}, **_req_kw, + ) as resp: + if resp.status in {200, 201}: + last_data = await _standalone_read_json_limited( + resp, _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES, + ) + caption_pending = False + except Exception: + logger.warning("Discord caption-fallback send failed for missing media") continue try: form = aiohttp.FormData() filename = os.path.basename(media_path) + if caption_pending: + form.add_field( + "payload_json", + json.dumps({"content": caption}), + content_type="application/json", + ) + caption_pending = False with open(media_path, "rb") as f: form.add_field("files[0]", f, filename=filename) async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp: diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index cd44c548418b..07f12cd513de 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -1569,12 +1569,18 @@ async def _standalone_send( thread_id=None, media_files=None, force_document=False, + caption=None, ): """Out-of-process WhatsApp delivery via the local bridge HTTP API. Implements the standalone_sender_fn contract so deliver=whatsapp cron jobs succeed when cron runs separately from the gateway. Replaces the legacy _send_whatsapp helper. + + When ``caption`` is provided (single-file ``MEDIA:<path> caption`` send), + the text rides on the media bubble's native caption via the bridge + ``/send-media`` ``caption`` field instead of being posted as a separate + ``/send`` message beforehand. """ extra = getattr(pconfig, "extra", {}) or {} try: @@ -1586,10 +1592,14 @@ async def _standalone_send( normalized_chat_id = to_whatsapp_jid(chat_id) media = media_files or [] text = message or "" + # A caption only applies to a single media file; guard defensively so + # a caption is never silently repeated across a multi-file send. + media_caption = caption if (caption and len(media) == 1) else None last_message_id = None async with aiohttp.ClientSession() as session: - # 1) Text first (skip the /send call when this chunk is media-only). - if text.strip(): + # 1) Text first (skip the /send call when this chunk is media-only + # or when the text is delivered as the media caption instead). + if text.strip() and not media_caption: async with session.post( f"http://localhost:{bridge_port}/send", json={"chatId": normalized_chat_id, "message": text}, @@ -1607,6 +1617,21 @@ async def _standalone_send( # bubble, and ogg/opus as a voice note — not a file/document. for media_path, is_voice in media: if not os.path.exists(media_path): + # If the text was suppressed to ride as this file's caption + # (caption mode), the words would otherwise be lost when the + # file is missing — deliver the caption as a plain message + # so nothing silently disappears. + if media_caption: + try: + async with session.post( + f"http://localhost:{bridge_port}/send", + json={"chatId": normalized_chat_id, "message": media_caption}, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + if resp.status == 200: + last_message_id = (await resp.json()).get("messageId") + except Exception: + logger.warning("WhatsApp caption-fallback send failed for missing media") return {"error": f"WhatsApp media file not found: {media_path}"} media_type = _bridge_media_type(media_path, is_voice, force_document) payload: Dict[str, Any] = { @@ -1616,6 +1641,8 @@ async def _standalone_send( } if media_type == "document": payload["fileName"] = os.path.basename(media_path) + if media_caption: + payload["caption"] = media_caption async with session.post( f"http://localhost:{bridge_port}/send-media", json=payload, diff --git a/tests/tools/test_discord_send_message_caption.py b/tests/tools/test_discord_send_message_caption.py new file mode 100644 index 000000000000..02e102904fd5 --- /dev/null +++ b/tests/tools/test_discord_send_message_caption.py @@ -0,0 +1,133 @@ +"""Discord standalone MEDIA:<path> caption delivery. + +When `hermes send --to discord "MEDIA:/x.png This Caption"` targets a normal +(non-forum) channel, the caption must ride on the media message content rather +than being posted as a separate message before the attachment. The Discord REST +calls are mocked at the aiohttp.ClientSession boundary. +""" + +import asyncio +import json +import os +import tempfile +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +from plugins.platforms.discord.adapter import _remember_channel_is_forum, _standalone_send + + +def _resp(status, json_data=None, text_data=None): + r = AsyncMock() + r.status = status + body = json.dumps(json_data or {}).encode() if json_data is not None else (text_data or "").encode() + r.json = AsyncMock(return_value=json_data or {}) + r.text = AsyncMock(return_value=text_data or "") + # Discord's _standalone_read_*_limited helpers stream resp.content.read(); + # return the body once then EOF so the bounded reader terminates. AsyncMock + # with a list side_effect yields each element on successive awaits. + r.content = MagicMock() + r.content.read = AsyncMock(side_effect=[body, b"", b""]) + # _standalone_response_encoding calls resp.get_encoding() expecting a str; + # a bare AsyncMock would return a coroutine. Give it a plain callable. + r.get_encoding = MagicMock(return_value="utf-8") + return r + + +def _session_with(responses): + """Mocked aiohttp.ClientSession recording every POST (url, json, data).""" + calls = [] + idx = [0] + + def _post(url, **kwargs): + calls.append((url, kwargs.get("json"), kwargs.get("data"))) + r = responses[idx[0]] if idx[0] < len(responses) else responses[-1] + idx[0] += 1 + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=r) + ctx.__aexit__ = AsyncMock(return_value=False) + return ctx + + session = MagicMock() + session.post = MagicMock(side_effect=_post) + session_ctx = MagicMock() + session_ctx.__aenter__ = AsyncMock(return_value=session) + session_ctx.__aexit__ = AsyncMock(return_value=False) + return session_ctx, calls + + +def _pconfig(): + return SimpleNamespace(token="bot-token", extra={}) + + +def _tmpfile(suffix): + f = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) + f.write(b"x") + f.close() + return f.name + + +def _payload_json_content(form_data): + """Extract the 'content' from a FormData's payload_json field, if any.""" + for field in getattr(form_data, "_fields", []): + # aiohttp FormData stores (type_options_dict, headers, value) + try: + type_opts = field[0] + value = field[2] + except (IndexError, TypeError): + continue + if type_opts.get("name") == "payload_json": + return json.loads(value).get("content") + return None + + +def test_caption_rides_media_non_forum(): + chat_id = "999000111" + _remember_channel_is_forum(chat_id, False) # avoid the live GET probe + img = _tmpfile(".png") + try: + session_ctx, calls = _session_with([_resp(200, {"id": "m1"})]) + with patch("aiohttp.ClientSession", return_value=session_ctx): + res = asyncio.run( + _standalone_send( + _pconfig(), + chat_id, + "", + media_files=[(img, False)], + caption="2-bedroom floor plan", + ) + ) + assert res["success"] is True + # Exactly one POST (the media upload) — no separate text message. + assert len(calls) == 1 + url, _json, data = calls[0] + assert url.endswith("/messages") + assert _payload_json_content(data) == "2-bedroom floor plan" + finally: + os.unlink(img) + + +def test_no_caption_non_forum_keeps_separate_text(): + """Without a caption, text + media are two separate POSTs (unchanged).""" + chat_id = "999000222" + _remember_channel_is_forum(chat_id, False) + img = _tmpfile(".png") + try: + session_ctx, calls = _session_with( + [_resp(200, {"id": "t1"}), _resp(200, {"id": "m1"})] + ) + with patch("aiohttp.ClientSession", return_value=session_ctx): + res = asyncio.run( + _standalone_send( + _pconfig(), + chat_id, + "hello", + media_files=[(img, False)], + ) + ) + assert res["success"] is True + # Two POSTs: the text content message, then the media upload. + assert len(calls) == 2 + assert calls[0][1] == {"content": "hello"} + assert calls[1][0].endswith("/messages") + finally: + os.unlink(img) diff --git a/tests/tools/test_media_caption_split.py b/tests/tools/test_media_caption_split.py new file mode 100644 index 000000000000..a4a1c7953177 --- /dev/null +++ b/tests/tools/test_media_caption_split.py @@ -0,0 +1,115 @@ +"""Guard test for the MEDIA:<path> caption chokepoint (_media_caption_split). + +`hermes send` strips the MEDIA: tag and leaves the remaining prose as the +accompanying text. Historically every standalone sender posted that text as a +*separate* message before an uncaptioned media bubble, splitting +``hermes send --to whatsapp "MEDIA:/x.png This Caption"`` into two parts. + +`_media_caption_split` is the single enforced decision point that all standalone +senders (WhatsApp, Telegram, Discord) consult to decide whether the text should +ride on the media bubble as a native caption. This test pins that contract so +the platforms can't diverge. +""" + +from tools.send_message_tool import ( + _DEFAULT_CAPTION_LIMIT, + _TELEGRAM_CAPTION_LIMIT, + _media_caption_split, +) + + +def test_single_image_short_text_becomes_caption(): + caption, body = _media_caption_split( + "This Caption", [("/tmp/F22.png", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption == "This Caption" + assert body == "" + + +def test_single_video_short_text_becomes_caption(): + caption, body = _media_caption_split( + "Model unit tour", [("/tmp/tour.mp4", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption == "Model unit tour" + assert body == "" + + +def test_single_document_short_text_becomes_caption(): + caption, body = _media_caption_split( + "Q3 report", [("/tmp/report.pdf", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption == "Q3 report" + assert body == "" + + +def test_multi_file_keeps_separate_body(): + text = "two photos" + caption, body = _media_caption_split( + text, + [("/tmp/a.png", False), ("/tmp/b.png", False)], + max_caption_len=_DEFAULT_CAPTION_LIMIT, + ) + assert caption is None + assert body == text + + +def test_voice_note_keeps_separate_body(): + text = "listen to this" + caption, body = _media_caption_split( + text, [("/tmp/note.ogg", True)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption is None + assert body == text + + +def test_empty_text_no_caption(): + caption, body = _media_caption_split( + " ", [("/tmp/a.png", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption is None + # body is returned unchanged (still whitespace) — sender's own guards drop it + assert body == " " + + +def test_no_media_no_caption(): + caption, body = _media_caption_split( + "hello", [], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption is None + assert body == "hello" + + +def test_text_over_limit_stays_separate_body(): + long_text = "x" * (_TELEGRAM_CAPTION_LIMIT + 1) + caption, body = _media_caption_split( + long_text, [("/tmp/a.png", False)], max_caption_len=_TELEGRAM_CAPTION_LIMIT + ) + assert caption is None + assert body == long_text + + +def test_text_at_limit_still_captions(): + text = "y" * _TELEGRAM_CAPTION_LIMIT + caption, body = _media_caption_split( + text, [("/tmp/a.png", False)], max_caption_len=_TELEGRAM_CAPTION_LIMIT + ) + assert caption == text + assert body == "" + + +def test_caption_is_stripped(): + caption, body = _media_caption_split( + " padded caption ", [("/tmp/a.png", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption == "padded caption" + assert body == "" + + +def test_unknown_extension_keeps_separate_body(): + # A non-captionable kind (e.g. an audio note that isn't flagged voice) + text = "some audio" + caption, body = _media_caption_split( + text, [("/tmp/song.mp3", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption is None + assert body == text diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index ed0423322632..55c5165901b6 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -146,11 +146,14 @@ class _patch_discord_sender: self._entry = None self._original = None - async def _adapter(self, pconfig, chat_id, message, *, thread_id=None, media_files=None): + async def _adapter(self, pconfig, chat_id, message, *, thread_id=None, media_files=None, caption=None): token = getattr(pconfig, "token", None) + # Only forward caption= when set, so mocks written against the + # pre-caption signature (no caption kwarg) keep working. + extra = {"caption": caption} if caption is not None else {} return await self._mock( token, chat_id, message, - thread_id=thread_id, media_files=media_files, + thread_id=thread_id, media_files=media_files, **extra, ) def __enter__(self): @@ -595,7 +598,9 @@ class TestSendMessageTool: class TestSendTelegramMediaDelivery: - def test_sends_text_then_photo_for_media_tag(self, tmp_path, monkeypatch): + def test_sends_photo_with_caption_for_media_tag(self, tmp_path, monkeypatch): + # A single captionable image + short text now rides as the photo's + # native caption (MEDIA:<path> caption), not a separate text message. image_path = tmp_path / "photo.png" image_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32) @@ -619,11 +624,10 @@ class TestSendTelegramMediaDelivery: assert result["success"] is True assert result["message_id"] == "2" - bot.send_message.assert_awaited_once() + # No separate text send — the caption rides the photo bubble. + bot.send_message.assert_not_awaited() bot.send_photo.assert_awaited_once() - sent_text = bot.send_message.await_args.kwargs["text"] - assert "MEDIA:" not in sent_text - assert sent_text == "Hello there" + assert bot.send_photo.await_args.kwargs.get("caption") == "Hello there" def test_sends_voice_for_ogg_with_voice_directive(self, tmp_path, monkeypatch): voice_path = tmp_path / "voice.ogg" @@ -2048,7 +2052,7 @@ class TestSendToPlatformDiscordMedia: assert call_log[1]["media_files"] == [("/fake/img.png", False)] # Last chunk: media attached def test_single_chunk_gets_media(self): - """Short message (single chunk) gets media_files directly.""" + """Short message + single image rides as the media caption.""" send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) with _patch_discord_sender(send_mock): @@ -2066,6 +2070,9 @@ class TestSendToPlatformDiscordMedia: send_mock.assert_awaited_once() call_kwargs = send_mock.await_args.kwargs assert call_kwargs["media_files"] == [("/fake/img.png", False)] + # Text rides as the caption, not a separate positional message body. + assert call_kwargs.get("caption") == "short message" + assert send_mock.await_args.args[2] == "" class TestSendMatrixUrlEncoding: @@ -3334,13 +3341,17 @@ class TestSendTelegramThreadNotFoundRetry: "retry should drop message_thread_id after thread-not-found" def test_disable_web_page_preview_not_leaked_to_media_sends(self): - """disable_web_page_preview should only appear in text send, not media sends.""" - text_kwargs_seen = [] + """disable_web_page_preview must never leak into a media send. + + A single captionable file + short text now rides as the document's + caption (no separate text send), so the invariant to protect is that + the captioned send_document does not inherit disable_web_page_preview + (valid only for send_message). + """ media_kwargs_seen = [] class FakeBot: async def send_message(self, **kwargs): - text_kwargs_seen.append(kwargs) return SimpleNamespace(message_id=1) async def send_document(self, **kwargs): @@ -3364,9 +3375,9 @@ class TestSendTelegramThreadNotFoundRetry: result = asyncio.run(run_test()) assert result["success"] is True - # Text send should have disable_web_page_preview - assert text_kwargs_seen[0].get("disable_web_page_preview") is True - # Media send should NOT have disable_web_page_preview + # Caption rides the document bubble. + assert media_kwargs_seen[0].get("caption") == "check preview" + # Media send must NOT carry disable_web_page_preview. assert "disable_web_page_preview" not in media_kwargs_seen[0], \ "disable_web_page_preview leaked into send_document kwargs" finally: diff --git a/tests/tools/test_telegram_send_message_caption.py b/tests/tools/test_telegram_send_message_caption.py new file mode 100644 index 000000000000..aa21331c5f93 --- /dev/null +++ b/tests/tools/test_telegram_send_message_caption.py @@ -0,0 +1,141 @@ +"""Standalone Telegram MEDIA:<path> caption delivery. + +When `hermes send --to telegram "MEDIA:/x.png This Caption"` carries a single +captionable file plus short text, the text must ride on the media bubble as the +sendPhoto/sendVideo/sendDocument ``caption`` rather than being posted as a +separate sendMessage beforehand. Longer text (> Telegram's 1024 caption cap) +falls back to a separate message. The ``telegram`` package is stubbed. +""" + +from __future__ import annotations + +import asyncio +import os +import sys +import tempfile +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +def _install_telegram_mock(monkeypatch: pytest.MonkeyPatch, bot_factory: MagicMock) -> None: + parse_mode = SimpleNamespace(MARKDOWN_V2="MarkdownV2", HTML="HTML") + constants_mod = SimpleNamespace(ParseMode=parse_mode) + _MessageEntity = lambda **_kw: SimpleNamespace(**_kw) + telegram_mod = SimpleNamespace( + Bot=bot_factory, + MessageEntity=_MessageEntity, + constants=constants_mod, + ) + monkeypatch.setitem(sys.modules, "telegram", telegram_mod) + monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod) + + +def _make_bot() -> MagicMock: + bot = MagicMock() + bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=1)) + bot.send_photo = AsyncMock(return_value=SimpleNamespace(message_id=2)) + bot.send_video = AsyncMock(return_value=SimpleNamespace(message_id=3)) + bot.send_document = AsyncMock(return_value=SimpleNamespace(message_id=4)) + return bot + + +def _no_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + for var in ( + "TELEGRAM_PROXY", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", + "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy", + ): + monkeypatch.delenv(var, raising=False) + monkeypatch.setattr("gateway.run._gateway_runner_ref", lambda: None, raising=False) + monkeypatch.setattr(sys, "platform", "linux") + + +def _tmpfile(suffix: str) -> str: + f = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) + f.write(b"x") + f.close() + return f.name + + +def test_image_caption_rides_bubble_no_separate_text(monkeypatch: pytest.MonkeyPatch) -> None: + from tools.send_message_tool import _send_telegram + + _no_proxy(monkeypatch) + bot = _make_bot() + _install_telegram_mock(monkeypatch, MagicMock(return_value=bot)) + img = _tmpfile(".png") + try: + res = asyncio.run( + _send_telegram("tok", "123", "This Caption", media_files=[(img, False)]) + ) + assert res["success"] is True + # No separate text message; caption rides the photo. + bot.send_message.assert_not_awaited() + bot.send_photo.assert_awaited_once() + assert bot.send_photo.await_args.kwargs.get("caption") == "This Caption" + finally: + os.unlink(img) + + +def test_video_caption_rides_bubble(monkeypatch: pytest.MonkeyPatch) -> None: + from tools.send_message_tool import _send_telegram + + _no_proxy(monkeypatch) + bot = _make_bot() + _install_telegram_mock(monkeypatch, MagicMock(return_value=bot)) + vid = _tmpfile(".mp4") + try: + res = asyncio.run( + _send_telegram("tok", "123", "Model unit tour", media_files=[(vid, False)]) + ) + assert res["success"] is True + bot.send_message.assert_not_awaited() + bot.send_video.assert_awaited_once() + assert bot.send_video.await_args.kwargs.get("caption") == "Model unit tour" + finally: + os.unlink(vid) + + +def test_long_text_falls_back_to_separate_message(monkeypatch: pytest.MonkeyPatch) -> None: + from tools.send_message_tool import _send_telegram + + _no_proxy(monkeypatch) + bot = _make_bot() + _install_telegram_mock(monkeypatch, MagicMock(return_value=bot)) + img = _tmpfile(".png") + long_text = "x" * 1100 # over Telegram's 1024 caption cap + try: + res = asyncio.run( + _send_telegram("tok", "123", long_text, media_files=[(img, False)]) + ) + assert res["success"] is True + # Text too long for a caption — sent as its own message, photo uncaptioned. + bot.send_message.assert_awaited() + bot.send_photo.assert_awaited_once() + assert not bot.send_photo.await_args.kwargs.get("caption") + finally: + os.unlink(img) + + +def test_multi_file_keeps_separate_text(monkeypatch: pytest.MonkeyPatch) -> None: + from tools.send_message_tool import _send_telegram + + _no_proxy(monkeypatch) + bot = _make_bot() + _install_telegram_mock(monkeypatch, MagicMock(return_value=bot)) + img = _tmpfile(".png") + img2 = _tmpfile(".jpg") + try: + res = asyncio.run( + _send_telegram("tok", "123", "two pics", media_files=[(img, False), (img2, False)]) + ) + assert res["success"] is True + # Ambiguous caption→file association: text stays a separate message. + bot.send_message.assert_awaited() + assert bot.send_photo.await_count == 2 + for call in bot.send_photo.await_args_list: + assert not call.kwargs.get("caption") + finally: + os.unlink(img) + os.unlink(img2) diff --git a/tests/tools/test_whatsapp_send_message_media.py b/tests/tools/test_whatsapp_send_message_media.py index d1fac4495e0f..eef890edf98d 100644 --- a/tests/tools/test_whatsapp_send_message_media.py +++ b/tests/tools/test_whatsapp_send_message_media.py @@ -219,3 +219,78 @@ def test_text_only_unchanged_behavior(): "message_id": "t1", } assert len(calls) == 1 and calls[0][0].endswith("/send") + + +def test_caption_rides_media_no_separate_text_send(): + """MEDIA:<path> caption -> single /send-media with caption, no /send.""" + img = _tmpfile(".png") + try: + session_ctx, calls = _session_with([_resp(200, {"messageId": "m1"})]) + with patch("aiohttp.ClientSession", return_value=session_ctx): + res = asyncio.run( + _standalone_send( + _pconfig(), + "12345", + "", + media_files=[(img, False)], + caption="2-bedroom floor plan", + ) + ) + assert res["success"] is True + # No separate /send — exactly one /send-media carrying the caption. + assert len(calls) == 1 + assert calls[0][0].endswith("/send-media") + assert calls[0][1]["caption"] == "2-bedroom floor plan" + assert calls[0][1]["mediaType"] == "image" + finally: + os.unlink(img) + + +def test_caption_ignored_for_multi_file_send(): + """A caption never rides a multi-file send (association is ambiguous).""" + img = _tmpfile(".png") + img2 = _tmpfile(".jpg") + try: + session_ctx, calls = _session_with( + [_resp(200, {"messageId": "m1"}), _resp(200, {"messageId": "m2"})] + ) + with patch("aiohttp.ClientSession", return_value=session_ctx): + res = asyncio.run( + _standalone_send( + _pconfig(), + "12345", + "", + media_files=[(img, False), (img2, False)], + caption="should be ignored", + ) + ) + assert res["success"] is True + media_calls = [c for c in calls if c[0].endswith("/send-media")] + assert len(media_calls) == 2 + assert all("caption" not in c[1] for c in media_calls) + finally: + os.unlink(img) + os.unlink(img2) + + +def test_missing_captioned_file_falls_back_to_text(): + """If the single captioned file is missing, the caption is delivered as a + plain /send message rather than being silently lost (W1).""" + session_ctx, calls = _session_with([_resp(200, {"messageId": "t1"})]) + with patch("aiohttp.ClientSession", return_value=session_ctx): + res = asyncio.run( + _standalone_send( + _pconfig(), + "12345", + "", + media_files=[("/no/such/file.png", False)], + caption="floor plan", + ) + ) + # The send still surfaces the missing-file error... + assert "error" in res + assert "not found" in res["error"] + # ...but the caption text was delivered on its own first. + assert len(calls) == 1 + assert calls[0][0].endswith("/send") + assert calls[0][1]["message"] == "floor plan" diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 5b0714b0ba1f..c143ea8064f1 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -65,6 +65,62 @@ _VOICE_EXTS = {".ogg", ".opus"} # formats either route through sendVoice (Opus/OGG) or fall back to # document delivery. _TELEGRAM_SEND_AUDIO_EXTS = {".mp3", ".m4a"} + +# Extensions that carry a native caption on the media bubble itself +# (photo/video/document). Voice/audio notes are excluded: a caption on a +# voice note reads as a separate label rather than a bubble caption, and the +# established convention is to keep the accompanying text as its own message. +_CAPTIONABLE_EXTS = _IMAGE_EXTS | _VIDEO_EXTS | { + ".pdf", ".doc", ".docx", ".txt", ".md", ".csv", ".xlsx", ".zip", +} + +# Per-platform native caption length limits (characters). Text longer than +# the limit can't ride on the media bubble and stays a separate body message. +# Telegram's photo/video caption cap is 1024; WhatsApp and Discord are far +# more generous, so a conservative shared ceiling keeps behavior predictable. +_TELEGRAM_CAPTION_LIMIT = 1024 +_DEFAULT_CAPTION_LIMIT = 4096 + + +def _media_caption_split(text, media_files, *, max_caption_len): + """Decide whether the accompanying text should ride on the media bubble. + + Single enforced chokepoint for the ``MEDIA:<path> caption`` behavior + across every standalone sender. ``hermes send`` (and the send_message + tool / cron) strips the ``MEDIA:`` tag and leaves the remaining prose as + ``text``; historically each platform sent that ``text`` as a *separate* + message before an uncaptioned media bubble, splitting the reported case + ``hermes send --to whatsapp "MEDIA:/x.png This Caption"`` into two parts. + + Returns ``(caption, body_text)``: + + * ``(caption, "")`` — attach ``text`` to the media as its native caption + and send *no* separate body message. Only when there is exactly one + media file, it is a captionable kind (image/video/document, not a + voice/audio note), and ``text`` fits ``max_caption_len``. + * ``(None, text)`` — keep the historical behavior: ``text`` is a separate + body message and the media carries no caption. Applies to multi-file + sends (caption→file association is ambiguous), voice/audio notes, empty + text, or text longer than the caption limit. + """ + stripped = (text or "").strip() + media = media_files or [] + if not stripped or len(media) != 1: + return None, text + media_path, is_voice = media[0] + if is_voice: + return None, text + ext = os.path.splitext(media_path)[1].lower() + if ext not in _CAPTIONABLE_EXTS: + return None, text + # Measure the caption in Unicode codepoints — a portable upper bound that + # never under-counts vs Telegram's UTF-16 units for BMP text, so an + # over-count only fails safe (falls back to a separate message). The + # Telegram call site additionally re-checks the *formatted* caption in + # UTF-16 units, since MarkdownV2/HTML escaping can inflate the length. + if len(stripped) > max_caption_len: + return None, text + return stripped, "" _URL_SECRET_QUERY_RE = re.compile( r"([?&](?:access_token|api[_-]?key|auth[_-]?token|token|signature|sig)=)([^&#\s]+)", re.IGNORECASE, @@ -814,6 +870,26 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, entry = platform_registry.get("discord") if entry is None or entry.standalone_sender_fn is None: return {"error": "Discord plugin not registered or missing standalone_sender_fn"} + # MEDIA:<path> caption: single captionable file + short text rides as + # the media message content instead of a separate message before the + # attachment (single enforced decision in _media_caption_split). Cap on + # the platform's own message limit so the caption is always deliverable. + _dc_caption, _ = _media_caption_split( + message, media_files, + max_caption_len=(max_len or _DEFAULT_CAPTION_LIMIT), + ) + if _dc_caption is not None: + result = await entry.standalone_sender_fn( + pconfig, + chat_id, + "", + thread_id=thread_id, + media_files=media_files, + caption=_dc_caption, + ) + if isinstance(result, dict) and result.get("error"): + return result + return result last_result = None for i, chunk in enumerate(chunks): is_last = (i == len(chunks) - 1) @@ -916,7 +992,30 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, _wa_entry = _pr_wa.get("whatsapp") if _wa_entry is None or _wa_entry.standalone_sender_fn is None: return {"error": "WhatsApp plugin not registered or missing standalone_sender_fn"} + # MEDIA:<path> caption: a single captionable file + short text rides + # as the media's native caption instead of a separate message before + # the bubble (single enforced decision in _media_caption_split). Cap on + # the platform's own message limit so the caption is always deliverable. + _wa_caption, _ = _media_caption_split( + message, media_files, + max_caption_len=(max_len or _DEFAULT_CAPTION_LIMIT), + ) last_result = None + if _wa_caption is not None: + # Single-file captioned send: no separate text chunk, caption on + # the media itself. + result = await _wa_entry.standalone_sender_fn( + pconfig, + chat_id, + "", + media_files=media_files, + thread_id=thread_id, + force_document=force_document, + caption=_wa_caption, + ) + if isinstance(result, dict) and result.get("error"): + return result + return result for i, chunk in enumerate(chunks): is_last = (i == len(chunks) - 1) result = await _wa_entry.standalone_sender_fn( @@ -1109,6 +1208,23 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No last_msg = None warnings = [] + # MEDIA:<path> caption: when a single captionable file is accompanied + # by short text, attach the text to the media bubble as its native + # caption instead of sending it as a separate message beforehand + # (single enforced decision in _media_caption_split). Caption with the + # *formatted* text so MarkdownV2/HTML styling is preserved, but guard + # the formatted length against Telegram's 1024 cap — formatting can + # inflate a raw-<1024 string past it, in which case fall back to a + # separate body message. + _tg_caption = None + from gateway.platforms.base import utf16_len as _utf16_len + _cap, _ = _media_caption_split( + message, media_files, max_caption_len=_TELEGRAM_CAPTION_LIMIT + ) + if _cap is not None and _utf16_len(formatted) <= _TELEGRAM_CAPTION_LIMIT: + _tg_caption = formatted + formatted = "" # suppress the separate text send below + if formatted.strip(): # Chunk *after* formatting: MarkdownV2/HTML escaping inflates the # text (each escaped char like `!`/`.`/`-` becomes `\!`/`\.`/`\-`), @@ -1170,12 +1286,34 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No warning = f"Media file not found, skipping: {media_path}" logger.warning(warning) warnings.append(warning) + # Caption mode suppressed the separate text send; if the file + # it was meant to caption is gone, deliver the caption text on + # its own so the words aren't silently lost. + if _tg_caption is not None and last_msg is None: + try: + last_msg = await _send_telegram_message_with_retry( + bot, chat_id=int_chat_id, text=_tg_caption, + parse_mode=send_parse_mode, **text_kwargs + ) + _tg_caption = None # delivered — don't re-caption a later file + except Exception as _cap_err: + logger.warning( + "Telegram caption-fallback send failed for missing media: %s", + _sanitize_error_text(_cap_err), + ) continue ext = os.path.splitext(media_path)[1].lower() try: with open(media_path, "rb") as f: media_kwargs = dict(thread_kwargs) + # Attach the MEDIA:<path> caption to the bubble itself for + # captionable kinds (photo/video/document). _tg_caption is + # only set for a single captionable file, so this never + # double-captions a multi-file send or a voice note. + if _tg_caption is not None and not (ext in _VOICE_EXTS and is_voice): + media_kwargs["caption"] = _tg_caption + media_kwargs["parse_mode"] = send_parse_mode try: if ext in _IMAGE_EXTS and not force_document: last_msg = await bot.send_photo( @@ -1228,6 +1366,37 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No last_msg = await bot.send_document( chat_id=int_chat_id, document=f, **media_kwargs ) + elif media_kwargs.get("parse_mode") and ( + "parse" in str(media_err).lower() + or "caption" in str(media_err).lower() + ): + # Caption failed to parse as MarkdownV2/HTML — + # retry with a plain-text caption so the media + # (and its caption) still deliver. + logger.warning( + "Caption parse failed for media send, retrying plain: %s", + _sanitize_error_text(media_err), + ) + f.seek(0) + media_kwargs.pop("parse_mode", None) + if not _has_html and media_kwargs.get("caption"): + try: + from plugins.platforms.telegram.adapter import _strip_mdv2 + media_kwargs["caption"] = _strip_mdv2(media_kwargs["caption"]) + except Exception: + pass + if ext in _IMAGE_EXTS and not force_document: + last_msg = await bot.send_photo( + chat_id=int_chat_id, photo=f, **media_kwargs + ) + elif ext in _VIDEO_EXTS: + last_msg = await bot.send_video( + chat_id=int_chat_id, video=f, **media_kwargs + ) + else: + last_msg = await bot.send_document( + chat_id=int_chat_id, document=f, **media_kwargs + ) else: raise except Exception as e: From d23990f527cbd33b528e0e107cfb3449e2f1a3fe Mon Sep 17 00:00:00 2001 From: embwl0x <embwl0x@users.noreply.github.com> Date: Wed, 8 Jul 2026 05:24:06 -0400 Subject: [PATCH 312/610] fix(gateway): offload channel directory session scans --- gateway/channel_directory.py | 11 ++-- tests/gateway/test_channel_directory.py | 80 +++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 0a9a2efddf84..ff207d86cb35 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -6,6 +6,7 @@ Built on gateway startup, refreshed periodically (every 5 min), and saved to action="list" and for resolving human-friendly channel names to numeric IDs. """ +import asyncio import json import logging from datetime import datetime @@ -121,7 +122,7 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: for platform, adapter in adapters.items(): try: if platform == Platform.DISCORD: - platforms["discord"] = _build_discord(adapter) + platforms["discord"] = await asyncio.to_thread(_build_discord, adapter) elif platform == Platform.SLACK: platforms["slack"] = await _build_slack(adapter) except Exception as e: @@ -142,7 +143,7 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: or plat_name not in adapter_platform_names ): continue - platforms[plat_name] = _build_from_sessions(plat_name) + platforms[plat_name] = await asyncio.to_thread(_build_from_sessions, plat_name) # Include plugin-registered platforms (dynamic enum members aren't in # Platform.__members__, so the loop above misses them). Same @@ -156,7 +157,7 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: and entry.name not in platforms and entry.name in adapter_platform_names ): - platforms[entry.name] = _build_from_sessions(entry.name) + platforms[entry.name] = await asyncio.to_thread(_build_from_sessions, entry.name) except Exception: pass @@ -223,7 +224,7 @@ async def _build_slack(adapter) -> List[Dict[str, Any]]: """ team_clients = getattr(adapter, "_team_clients", None) or {} if not team_clients: - return _build_from_sessions("slack") + return await asyncio.to_thread(_build_from_sessions, "slack") channels: List[Dict[str, Any]] = [] seen_ids: set = set() @@ -267,7 +268,7 @@ async def _build_slack(adapter) -> List[Dict[str, Any]]: continue # Merge in DM/group entries discovered from session history. - for entry in _build_from_sessions("slack"): + for entry in await asyncio.to_thread(_build_from_sessions, "slack"): if entry.get("id") not in seen_ids: channels.append(entry) seen_ids.add(entry.get("id")) diff --git a/tests/gateway/test_channel_directory.py b/tests/gateway/test_channel_directory.py index 8c32eb8f409b..b30713163a20 100644 --- a/tests/gateway/test_channel_directory.py +++ b/tests/gateway/test_channel_directory.py @@ -3,6 +3,7 @@ import asyncio import json import os +import threading from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -84,6 +85,85 @@ class TestBuildChannelDirectoryWrites: assert result == previous +class TestBuildChannelDirectoryOffload: + def test_discord_builder_runs_off_event_loop_thread(self, tmp_path): + from gateway.config import Platform + + cache_file = tmp_path / "channel_directory.json" + loop_thread = threading.get_ident() + builder_threads = [] + + def fake_build_discord(_adapter): + builder_threads.append(threading.get_ident()) + return [] + + with patch("gateway.channel_directory._build_discord", side_effect=fake_build_discord), \ + patch("gateway.channel_directory.DIRECTORY_PATH", cache_file): + asyncio.run(build_channel_directory({Platform.DISCORD: object()})) + + assert builder_threads + assert all(tid != loop_thread for tid in builder_threads) + + def test_session_discovery_runs_off_event_loop_thread(self, tmp_path): + from gateway.config import Platform + + cache_file = tmp_path / "channel_directory.json" + loop_thread = threading.get_ident() + calls = [] + + def fake_build_from_sessions(platform_name): + calls.append((platform_name, threading.get_ident())) + return [] + + with patch("gateway.channel_directory._build_from_sessions", side_effect=fake_build_from_sessions), \ + patch("gateway.channel_directory.DIRECTORY_PATH", cache_file): + asyncio.run(build_channel_directory({Platform.TELEGRAM: object()})) + + assert [name for name, _ in calls] == ["telegram"] + assert calls[0][1] != loop_thread + + def test_plugin_session_discovery_runs_off_event_loop_thread(self, tmp_path): + cache_file = tmp_path / "channel_directory.json" + loop_thread = threading.get_ident() + calls = [] + plugin_entry = SimpleNamespace(name="irc") + + def fake_build_from_sessions(platform_name): + calls.append((platform_name, threading.get_ident())) + return [] + + with patch("gateway.channel_directory._build_from_sessions", side_effect=fake_build_from_sessions), \ + patch("gateway.channel_directory.DIRECTORY_PATH", cache_file), \ + patch( + "gateway.platform_registry.platform_registry.plugin_entries", + return_value=[plugin_entry], + ): + asyncio.run(build_channel_directory({"irc": object()})) + + assert [name for name, _ in calls] == ["irc"] + assert calls[0][1] != loop_thread + + def test_slack_session_merge_runs_off_event_loop_thread(self): + loop_thread = threading.get_ident() + calls = [] + + class FakeSlackClient: + async def users_conversations(self, **_kwargs): + return {"ok": True, "channels": []} + + def fake_build_from_sessions(platform_name): + calls.append((platform_name, threading.get_ident())) + return [{"id": "D1", "name": "Alice", "type": "dm"}] + + adapter = SimpleNamespace(_team_clients={"T1": FakeSlackClient()}) + with patch("gateway.channel_directory._build_from_sessions", side_effect=fake_build_from_sessions): + channels = asyncio.run(_build_slack(adapter)) + + assert channels == [{"id": "D1", "name": "Alice", "type": "dm"}] + assert [name for name, _ in calls] == ["slack"] + assert calls[0][1] != loop_thread + + class TestResolveChannelName: def _setup(self, tmp_path, platforms): cache_file = _write_directory(tmp_path, platforms) From daedf4f627c73859974e587783b7cef8ce80e19a Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:23:12 -0700 Subject: [PATCH 313/610] chore: AUTHOR_MAP entry for embwl0x (PR #60810 salvage) --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 500c2ca05f55..788cb157bfd8 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -300,6 +300,7 @@ AUTHOR_MAP = { "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", "iamgexin@qq.com": "nullptr0807", # PR #60956 salvage (gateway hygiene in-place compaction; #60947) + "embwl0x@users.noreply.github.com": "embwl0x", # PR #60810 salvage (channel directory offload) "caztronics@yahoo.com": "doncazper", "30668368+alex107ivanov@users.noreply.github.com": "alex107ivanov", "210088133+rungmc357@users.noreply.github.com": "rungmc357", From 3a1a3c7e6727a31df89b61b27bad313430bdac45 Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:20:40 -0400 Subject: [PATCH 314/610] add 5.6 (#61578) --- hermes_cli/models.py | 6 ++++++ website/static/api/model-catalog.json | 23 ++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index e82993ac42d7..bcde00c47b47 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -40,6 +40,9 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ ("anthropic/claude-sonnet-5", ""), ("anthropic/claude-haiku-4.5", ""), # OpenAI + ("openai/gpt-5.6-sol", ""), + ("openai/gpt-5.6-terra", ""), + ("openai/gpt-5.6-luna", ""), ("openai/gpt-5.5", ""), ("openai/gpt-5.5-pro", ""), ("openai/gpt-5.4-mini", ""), @@ -185,6 +188,9 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "anthropic/claude-sonnet-5", "anthropic/claude-haiku-4.5", # OpenAI + "openai/gpt-5.6-sol", + "openai/gpt-5.6-terra", + "openai/gpt-5.6-luna", "openai/gpt-5.5", "openai/gpt-5.5-pro", "openai/gpt-5.4-mini", diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index 99aeb4262a8f..1f3ab720973d 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-07-08T19:45:27Z", + "updated_at": "2026-07-09T17:15:00Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" @@ -32,6 +32,18 @@ "id": "anthropic/claude-haiku-4.5", "description": "" }, + { + "id": "openai/gpt-5.6-sol", + "description": "" + }, + { + "id": "openai/gpt-5.6-terra", + "description": "" + }, + { + "id": "openai/gpt-5.6-luna", + "description": "" + }, { "id": "openai/gpt-5.5", "description": "" @@ -168,6 +180,15 @@ { "id": "anthropic/claude-haiku-4.5" }, + { + "id": "openai/gpt-5.6-sol" + }, + { + "id": "openai/gpt-5.6-terra" + }, + { + "id": "openai/gpt-5.6-luna" + }, { "id": "openai/gpt-5.5" }, From bd767b574be4c65036d2bef2e74b189704d39cad Mon Sep 17 00:00:00 2001 From: Kshitij Kapoor <kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:13:51 +0530 Subject: [PATCH 315/610] =?UTF-8?q?feat(openai):=20complete=20gpt-5.6=20re?= =?UTF-8?q?gistration=20=E2=80=94=20context,=20codex=20catalog,=20native?= =?UTF-8?q?=20picker,=20pricing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #61578 added the GPT-5.6 series (sol/terra/luna) to the two aggregator surfaces (OPENROUTER_MODELS, _PROVIDER_MODELS[nous]). This completes the registration on the remaining surfaces per the standard add-model checklist: - agent/model_metadata.py: DEFAULT_CONTEXT_LENGTHS 1.05M (direct API, same as gpt-5.5; more-specific keys precede gpt-5.5 for longest-substring matching) + _CODEX_OAUTH_CONTEXT_FALLBACK 272K for all three slugs. Without these the direct-API fallback matched generic "gpt-5" = 400K. - hermes_cli/codex_models.py: DEFAULT_CODEX_MODELS + forward-compat templates so ChatGPT-OAuth (openai-codex) pickers surface the series. - hermes_cli/models.py: _PROVIDER_MODELS[openai-api] (native API picker). - agent/usage_pricing.py: _OFFICIAL_DOCS_PRICING snapshot — sol 5/30, terra 2.50/15, luna 1/6 per 1M in/out; cache read 0.10x input, cache write 1.25x input (OpenAI billing change starting with the 5.6 series). GA 2026-07-09 at preview rates. Sol Fast mode (Cerebras tier) excluded. - hermes_cli/model_switch.py: rank "sol" as a flagship suffix so /model gpt resolves to gpt-5.6-sol, not alphabetical-first luna. Verified: registry E2E via real imports (both context tables, codex forward-compat from a gpt-5.5 template, billing-route lookup for openai/gpt-5.6-sol -> 5.00/M), alias resolution on openai-codex and openai-api resolves to gpt-5.6-sol; 183 targeted tests pass (model_metadata, usage_pricing, codex_models, model_catalog). --- agent/model_metadata.py | 9 ++++++++ agent/usage_pricing.py | 44 ++++++++++++++++++++++++++++++++++++++ hermes_cli/codex_models.py | 7 ++++++ hermes_cli/model_switch.py | 5 ++++- hermes_cli/models.py | 3 +++ 5 files changed, 67 insertions(+), 1 deletion(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 3e966899b9c8..ada9cd155361 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -229,6 +229,12 @@ DEFAULT_CONTEXT_LENGTHS = { # ChatGPT Codex OAuth caps it at 272K; both paths resolve via their own # provider-aware branches (_resolve_codex_oauth_context_length + models.dev). # This hardcoded value is only reached when every probe misses. + # GPT-5.6 series (Sol/Terra/Luna, GA 2026-07-09) — 1.05M on the direct + # OpenAI API (same as gpt-5.5). Codex OAuth caps these at 272K. + # More-specific keys precede "gpt-5.5" for longest-substring matching. + "gpt-5.6-luna": 1050000, + "gpt-5.6-terra": 1050000, + "gpt-5.6-sol": 1050000, "gpt-5.5": 1050000, "gpt-5.4-nano": 400000, # 400k (not 1.05M like full 5.4) "gpt-5.4-mini": 400000, # 400k (not 1.05M like full 5.4) @@ -1837,6 +1843,9 @@ _CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = { "gpt-5.3-codex-spark": 128_000, "gpt-5.2-codex": 272_000, "gpt-5.4-mini": 272_000, + "gpt-5.6-sol": 272_000, + "gpt-5.6-terra": 272_000, + "gpt-5.6-luna": 272_000, "gpt-5.5": 272_000, "gpt-5.4": 272_000, "gpt-5.2": 272_000, diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index d7b56a9fac42..112ab21be719 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -103,6 +103,50 @@ _UTC_NOW = lambda: datetime.now(timezone.utc) # Official docs snapshot entries. Models whose published pricing and cache # semantics are stable enough to encode exactly. _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = { + # ── OpenAI GPT-5.6 series (Sol/Terra/Luna) ─────────────────────────── + # Announced in limited preview 2026-06-26; GA 2026-07-09 at the same + # rates (Sol $5/$30, Terra $2.50/$15, Luna $1/$6 per 1M in/out). Cache + # writes are billed at 1.25x the uncached input rate; cache reads get the + # standard 90% discount (0.10x input, confirmed: Sol $0.50/M cached). + # Note: "Sol Fast mode" ($12.5/$75, up to 750 tok/s via Cerebras) is a + # separate serving tier, not covered by these entries. + # Source: https://openai.com/index/previewing-gpt-5-6-sol/ + ( + "openai", + "gpt-5.6-sol", + ): PricingEntry( + input_cost_per_million=Decimal("5.00"), + output_cost_per_million=Decimal("30.00"), + cache_read_cost_per_million=Decimal("0.50"), + cache_write_cost_per_million=Decimal("6.25"), + source="official_docs_snapshot", + source_url="https://openai.com/index/previewing-gpt-5-6-sol/", + pricing_version="openai-gpt-5.6-preview-2026-07", + ), + ( + "openai", + "gpt-5.6-terra", + ): PricingEntry( + input_cost_per_million=Decimal("2.50"), + output_cost_per_million=Decimal("15.00"), + cache_read_cost_per_million=Decimal("0.25"), + cache_write_cost_per_million=Decimal("3.125"), + source="official_docs_snapshot", + source_url="https://openai.com/index/previewing-gpt-5-6-sol/", + pricing_version="openai-gpt-5.6-preview-2026-07", + ), + ( + "openai", + "gpt-5.6-luna", + ): PricingEntry( + input_cost_per_million=Decimal("1.00"), + output_cost_per_million=Decimal("6.00"), + cache_read_cost_per_million=Decimal("0.10"), + cache_write_cost_per_million=Decimal("1.25"), + source="official_docs_snapshot", + source_url="https://openai.com/index/previewing-gpt-5-6-sol/", + pricing_version="openai-gpt-5.6-preview-2026-07", + ), # ── Anthropic Claude 4.8 ───────────────────────────────────────────── # Same $5/$25 base pricing as 4.6/4.7. Fast-mode variant is a separate # model ID with 2x premium (vs the 6x premium on older Opus generations). diff --git a/hermes_cli/codex_models.py b/hermes_cli/codex_models.py index 768e68bee381..bd7b2d78e8be 100644 --- a/hermes_cli/codex_models.py +++ b/hermes_cli/codex_models.py @@ -12,6 +12,10 @@ import os logger = logging.getLogger(__name__) DEFAULT_CODEX_MODELS: List[str] = [ + # GPT-5.6 series (Sol/Terra/Luna) — GA 2026-07-09 (previewed 2026-06-26). + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", "gpt-5.5", "gpt-5.4-mini", "gpt-5.4", @@ -44,6 +48,9 @@ DEFAULT_CODEX_MODELS: List[str] = [ ] _FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [ + ("gpt-5.6-sol", ("gpt-5.5", "gpt-5.4")), + ("gpt-5.6-terra", ("gpt-5.5", "gpt-5.4")), + ("gpt-5.6-luna", ("gpt-5.5", "gpt-5.4")), ("gpt-5.5", ("gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex")), ("gpt-5.4-mini", ("gpt-5.3-codex",)), ("gpt-5.4", ("gpt-5.3-codex",)), diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 5d9ecfd20508..2767b088a7d1 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -548,7 +548,10 @@ def _model_sort_key(model_id: str, prefix: str) -> tuple: # Suffix quality ranking: pro/max > (no suffix) > omni/flash/mini/lite # Lower number = preferred - _SUFFIX_RANK = {"pro": 0, "max": 0, "plus": 0, "turbo": 0} + # "sol" is the flagship tier of the GPT-5.6 series (sol > terra > luna); + # without it, alias resolution would tiebreak alphabetically and pick + # luna (the cheapest) for `/model gpt`. + _SUFFIX_RANK = {"pro": 0, "max": 0, "plus": 0, "turbo": 0, "sol": 0} suffix_rank = _SUFFIX_RANK.get(suffix, 1) return version_key + (suffix_rank, suffix) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index bcde00c47b47..af2aba931dcd 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -239,6 +239,9 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "gpt-4o-mini", ], "openai-api": [ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", "gpt-5.5", "gpt-5.5-pro", "gpt-5.4", From db117af4785f79d0adfafcea4d75ee556f4006dd Mon Sep 17 00:00:00 2001 From: Kshitij Kapoor <kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:27:35 +0530 Subject: [PATCH 316/610] review fixes: openai-api pricing route normalization, GA pricing_version, invariant tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-2 review findings addressed: - resolve_billing_route: normalize the "openai-api" picker slug to the "openai" billing provider — without this the ("openai", <model>) _OFFICIAL_DOCS_PRICING keys (incl. every pre-existing gpt-4o/gpt-4.1 entry, not just 5.6) were unreachable when the provider is openai-api. - pricing_version: drop the "preview" tag (GA 2026-07-09 at same rates). - model_metadata comment: dict order is cosmetic — lookups length-sort keys at match time; the old comment implied a positional invariant. - model_switch comment: note "sol" is a series codename, not a generic quality word. - tests/hermes_cli/test_gpt56_registration.py: behavior contracts (no list snapshots) — sol > terra/luna > 5.5 sort invariant, pricing reachability from both openai and openai-api routes, cache-write 1.25x / cache-read 0.10x input relation. --- agent/model_metadata.py | 2 +- agent/usage_pricing.py | 12 ++-- hermes_cli/model_switch.py | 4 +- tests/hermes_cli/test_gpt56_registration.py | 66 +++++++++++++++++++++ 4 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 tests/hermes_cli/test_gpt56_registration.py diff --git a/agent/model_metadata.py b/agent/model_metadata.py index ada9cd155361..dbbcf81b72fb 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -231,7 +231,7 @@ DEFAULT_CONTEXT_LENGTHS = { # This hardcoded value is only reached when every probe misses. # GPT-5.6 series (Sol/Terra/Luna, GA 2026-07-09) — 1.05M on the direct # OpenAI API (same as gpt-5.5). Codex OAuth caps these at 272K. - # More-specific keys precede "gpt-5.5" for longest-substring matching. + # (Lookups length-sort keys at match time, so dict order is cosmetic.) "gpt-5.6-luna": 1050000, "gpt-5.6-terra": 1050000, "gpt-5.6-sol": 1050000, diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index 112ab21be719..99d593ce6b2d 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -121,7 +121,7 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = { cache_write_cost_per_million=Decimal("6.25"), source="official_docs_snapshot", source_url="https://openai.com/index/previewing-gpt-5-6-sol/", - pricing_version="openai-gpt-5.6-preview-2026-07", + pricing_version="openai-gpt-5.6-2026-07", ), ( "openai", @@ -133,7 +133,7 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = { cache_write_cost_per_million=Decimal("3.125"), source="official_docs_snapshot", source_url="https://openai.com/index/previewing-gpt-5-6-sol/", - pricing_version="openai-gpt-5.6-preview-2026-07", + pricing_version="openai-gpt-5.6-2026-07", ), ( "openai", @@ -145,7 +145,7 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = { cache_write_cost_per_million=Decimal("1.25"), source="official_docs_snapshot", source_url="https://openai.com/index/previewing-gpt-5-6-sol/", - pricing_version="openai-gpt-5.6-preview-2026-07", + pricing_version="openai-gpt-5.6-2026-07", ), # ── Anthropic Claude 4.8 ───────────────────────────────────────────── # Same $5/$25 base pricing as 4.6/4.7. Fast-mode variant is a separate @@ -646,7 +646,11 @@ def resolve_billing_route( return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api") if provider_name == "anthropic": return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot") - if provider_name == "openai": + # "openai-api" is the picker/registry slug for direct api.openai.com; it + # bills identically to bare "openai", so normalize it here — otherwise the + # ("openai", <model>) _OFFICIAL_DOCS_PRICING keys are unreachable from the + # openai-api provider path. + if provider_name in {"openai", "openai-api"}: return BillingRoute(provider="openai", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot") if provider_name in {"minimax", "minimax-cn"}: return BillingRoute(provider=provider_name, model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot") diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 2767b088a7d1..4bf75fe3117f 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -550,7 +550,9 @@ def _model_sort_key(model_id: str, prefix: str) -> tuple: # Lower number = preferred # "sol" is the flagship tier of the GPT-5.6 series (sol > terra > luna); # without it, alias resolution would tiebreak alphabetically and pick - # luna (the cheapest) for `/model gpt`. + # luna (the cheapest) for `/model gpt`. Unlike pro/max/plus/turbo it is a + # series codename, not a generic quality word — revisit if another vendor + # ever ships a "-sol" suffix that isn't a flagship. _SUFFIX_RANK = {"pro": 0, "max": 0, "plus": 0, "turbo": 0, "sol": 0} suffix_rank = _SUFFIX_RANK.get(suffix, 1) diff --git a/tests/hermes_cli/test_gpt56_registration.py b/tests/hermes_cli/test_gpt56_registration.py new file mode 100644 index 000000000000..f88a07cb6c53 --- /dev/null +++ b/tests/hermes_cli/test_gpt56_registration.py @@ -0,0 +1,66 @@ +"""Behavior contracts for the GPT-5.6 (Sol/Terra/Luna) registration. + +Invariant tests only — no list snapshots (per the no-change-detector-tests +policy). These pin the two behaviors that would silently regress: + +1. Version/tier sorting: the flagship Sol must outrank Terra/Luna and the + whole 5.6 series must outrank 5.5, so `/model gpt` resolves to the + flagship rather than an alphabetical-first cheap tier. +2. Pricing reachability: the ("openai", <model>) official-docs pricing keys + must be reachable from BOTH the bare "openai" provider and the + "openai-api" picker slug (resolve_billing_route normalizes the latter). +""" + +from decimal import Decimal + +from agent.usage_pricing import ( + _OFFICIAL_DOCS_PRICING, + _lookup_official_docs_pricing, + resolve_billing_route, +) +from hermes_cli.model_switch import _model_sort_key + + +class TestGpt56SortInvariants: + def test_sol_outranks_terra_and_luna(self): + models = ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"] + models.sort(key=lambda m: _model_sort_key(m, "gpt")) + assert models[0] == "gpt-5.6-sol" + + def test_56_series_outranks_55(self): + models = ["gpt-5.5", "gpt-5.5-pro", "gpt-5.6-sol"] + models.sort(key=lambda m: _model_sort_key(m, "gpt")) + assert models[0] == "gpt-5.6-sol" + + def test_aggregator_prefix_form(self): + models = ["openai/gpt-5.5-pro", "openai/gpt-5.6-sol"] + models.sort(key=lambda m: _model_sort_key(m, "openai/gpt")) + assert models[0] == "openai/gpt-5.6-sol" + + +class TestGpt56PricingRoute: + def test_official_pricing_reachable_from_openai(self): + route = resolve_billing_route("gpt-5.6-sol", provider="openai") + entry = _lookup_official_docs_pricing(route) + assert entry is not None + assert entry.input_cost_per_million == Decimal("5.00") + + def test_official_pricing_reachable_from_openai_api_slug(self): + # "openai-api" is the picker slug for direct api.openai.com and must + # normalize to the "openai" pricing key space. + route = resolve_billing_route("gpt-5.6-sol", provider="openai-api") + assert route.provider == "openai" + entry = _lookup_official_docs_pricing(route) + assert entry is not None + assert entry.input_cost_per_million == Decimal("5.00") + + def test_cache_write_is_1_25x_input_for_56_series(self): + for slug in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"): + entry = _OFFICIAL_DOCS_PRICING[("openai", slug)] + assert entry.input_cost_per_million is not None, slug + assert entry.cache_write_cost_per_million == ( + entry.input_cost_per_million * Decimal("1.25") + ), slug + assert entry.cache_read_cost_per_million == ( + entry.input_cost_per_million * Decimal("0.10") + ), slug From a3828a94d071a44b0c9e97a2923890f84c8179c5 Mon Sep 17 00:00:00 2001 From: Kshitij Kapoor <kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:42:12 +0530 Subject: [PATCH 317/610] feat(openai): cover gpt-5.6 -pro variants (PR #61587 complement) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #61587 adds sol-pro/terra-pro/luna-pro to the aggregator lists. Complete those on the native surfaces the same way this PR completes the base tiers: - hermes_cli/models.py: -pro variants in _PROVIDER_MODELS[openai-api]. - agent/usage_pricing.py: alias ("openai", "gpt-5.6-*-pro") onto the base-tier PricingEntry rows — the -pro high-effort modes bill at the SAME per-token rates (verified against OpenRouter live pricing 2026-07-09: identical prompt/completion prices for base and -pro); they cost more per task by consuming more tokens, not a higher rate. - Context lengths need no new entries: "gpt-5.6-sol" et al. are substrings of their -pro variants and both lookup tables match longest-key-first (verified: sol-pro -> 1.05M direct / 272K codex). - model_switch sort: -pro variants parse as suffix "sol-pro" (rank 1), so /model gpt still defaults to base sol — pinned by test. - Not added to DEFAULT_CODEX_MODELS: only confirmed routable via API/ OpenRouter so far; codex live discovery will surface them if ChatGPT exposes them, same policy as other unconfirmed codex slugs. Tests: invariant tests extended (pro aliases share base entries, base sol outranks sol-pro); 191 targeted tests pass. --- agent/usage_pricing.py | 15 ++++++++++++++- hermes_cli/models.py | 3 +++ tests/hermes_cli/test_gpt56_registration.py | 17 +++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index 99d593ce6b2d..aa306fa12f8c 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -109,7 +109,11 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = { # writes are billed at 1.25x the uncached input rate; cache reads get the # standard 90% discount (0.10x input, confirmed: Sol $0.50/M cached). # Note: "Sol Fast mode" ($12.5/$75, up to 750 tok/s via Cerebras) is a - # separate serving tier, not covered by these entries. + # separate serving tier, not covered by these entries. The "-pro" + # variants (high-effort modes, GA alongside base tiers) bill at the + # SAME per-token rates and are aliased onto these entries below the + # dict (they cost more per task by consuming more tokens, not by a + # higher rate — verified against OpenRouter's live pricing 2026-07-09). # Source: https://openai.com/index/previewing-gpt-5-6-sol/ ( "openai", @@ -607,6 +611,15 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = { ), } +# GPT-5.6 "-pro" high-effort variants bill at the same per-token rates as +# their base tiers (more tokens per task, not a higher rate). Alias them +# onto the base entries so the snapshot stays single-source. +for _base_56 in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"): + _OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-pro")] = _OFFICIAL_DOCS_PRICING[ + ("openai", _base_56) + ] +del _base_56 + def _to_decimal(value: Any) -> Optional[Decimal]: if value is None: diff --git a/hermes_cli/models.py b/hermes_cli/models.py index af2aba931dcd..2f3ae5c06269 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -240,8 +240,11 @@ _PROVIDER_MODELS: dict[str, list[str]] = { ], "openai-api": [ "gpt-5.6-sol", + "gpt-5.6-sol-pro", "gpt-5.6-terra", + "gpt-5.6-terra-pro", "gpt-5.6-luna", + "gpt-5.6-luna-pro", "gpt-5.5", "gpt-5.5-pro", "gpt-5.4", diff --git a/tests/hermes_cli/test_gpt56_registration.py b/tests/hermes_cli/test_gpt56_registration.py index f88a07cb6c53..9d63992ecaab 100644 --- a/tests/hermes_cli/test_gpt56_registration.py +++ b/tests/hermes_cli/test_gpt56_registration.py @@ -38,6 +38,14 @@ class TestGpt56SortInvariants: assert models[0] == "openai/gpt-5.6-sol" + def test_base_sol_outranks_sol_pro_for_alias_default(self): + # "-pro" high-effort variants parse as suffix "sol-pro" (rank 1), so + # `/model gpt` defaults to base Sol rather than the high-effort mode. + models = ["gpt-5.6-sol-pro", "gpt-5.6-sol"] + models.sort(key=lambda m: _model_sort_key(m, "gpt")) + assert models[0] == "gpt-5.6-sol" + + class TestGpt56PricingRoute: def test_official_pricing_reachable_from_openai(self): route = resolve_billing_route("gpt-5.6-sol", provider="openai") @@ -64,3 +72,12 @@ class TestGpt56PricingRoute: assert entry.cache_read_cost_per_million == ( entry.input_cost_per_million * Decimal("0.10") ), slug + + def test_pro_variants_alias_to_base_tier_pricing(self): + # -pro high-effort modes bill at the same per-token rates as their + # base tiers; the snapshot aliases them rather than duplicating rows. + for base in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"): + assert ( + _OFFICIAL_DOCS_PRICING[("openai", f"{base}-pro")] + is _OFFICIAL_DOCS_PRICING[("openai", base)] + ), base From 7efee32868ecd96f2bc8fb00cfea3fd77207450d Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:42:11 -0400 Subject: [PATCH 318/610] pro variants --- hermes_cli/models.py | 6 ++++++ website/static/api/model-catalog.json | 23 ++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 2f3ae5c06269..19006eef3801 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -41,8 +41,11 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ ("anthropic/claude-haiku-4.5", ""), # OpenAI ("openai/gpt-5.6-sol", ""), + ("openai/gpt-5.6-sol-pro", ""), ("openai/gpt-5.6-terra", ""), + ("openai/gpt-5.6-terra-pro", ""), ("openai/gpt-5.6-luna", ""), + ("openai/gpt-5.6-luna-pro", ""), ("openai/gpt-5.5", ""), ("openai/gpt-5.5-pro", ""), ("openai/gpt-5.4-mini", ""), @@ -189,8 +192,11 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "anthropic/claude-haiku-4.5", # OpenAI "openai/gpt-5.6-sol", + "openai/gpt-5.6-sol-pro", "openai/gpt-5.6-terra", + "openai/gpt-5.6-terra-pro", "openai/gpt-5.6-luna", + "openai/gpt-5.6-luna-pro", "openai/gpt-5.5", "openai/gpt-5.5-pro", "openai/gpt-5.4-mini", diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index 1f3ab720973d..a9f9e52306d2 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-07-09T17:15:00Z", + "updated_at": "2026-07-09T17:40:00Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" @@ -36,14 +36,26 @@ "id": "openai/gpt-5.6-sol", "description": "" }, + { + "id": "openai/gpt-5.6-sol-pro", + "description": "" + }, { "id": "openai/gpt-5.6-terra", "description": "" }, + { + "id": "openai/gpt-5.6-terra-pro", + "description": "" + }, { "id": "openai/gpt-5.6-luna", "description": "" }, + { + "id": "openai/gpt-5.6-luna-pro", + "description": "" + }, { "id": "openai/gpt-5.5", "description": "" @@ -183,12 +195,21 @@ { "id": "openai/gpt-5.6-sol" }, + { + "id": "openai/gpt-5.6-sol-pro" + }, { "id": "openai/gpt-5.6-terra" }, + { + "id": "openai/gpt-5.6-terra-pro" + }, { "id": "openai/gpt-5.6-luna" }, + { + "id": "openai/gpt-5.6-luna-pro" + }, { "id": "openai/gpt-5.5" }, From 5da7b23d6fdb8cd482fa223e5f887305828d8c0a Mon Sep 17 00:00:00 2001 From: Kshitij Kapoor <kshitijk4poor@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:09:24 +0530 Subject: [PATCH 319/610] chore(catalog): regenerate model-catalog.json from source Rerun scripts/build_model_catalog.py so the manifest is source-generated rather than hand-edited (the -pro rows from the cherry-picked #61587 were already correct; only updated_at changes). --- website/static/api/model-catalog.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index a9f9e52306d2..b829b00eb03d 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-07-09T17:40:00Z", + "updated_at": "2026-07-09T18:38:59Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" From 4af484d3ddf305427822d42aa8ddbf3cb3b50fe3 Mon Sep 17 00:00:00 2001 From: Kshitij Kapoor <kshitijk4poor@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:26:28 +0530 Subject: [PATCH 320/610] =?UTF-8?q?feat(openai):=20complete=20gpt-5.6=20E2?= =?UTF-8?q?E=20=E2=80=94=20codex=20catalog=20+=20272K=20compaction=20auto-?= =?UTF-8?q?raise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the remaining end-to-end gaps so the full gpt-5.6 family (sol/ terra/luna + their -pro high-effort modes, 6 slugs) works on every surface a user can reach them through: - agent/auxiliary_client.py: the Codex OAuth backend hard-caps context at 272K for gpt-5.6 exactly as it does for 5.4/5.5, but the default 50% compaction trigger would summarize at ~136K and waste half the usable window. Extend the existing _is_codex_gpt54_or_gpt55 chokepoint (single enforced predicate feeding _compression_threshold_for_model) to match gpt-5.6* on the openai-codex route so those sessions get the same 0.85 auto-raise. Direct-API/OpenRouter routes (full 1.05M window) are unaffected; the historical codex_gpt55_autoraise opt-out still applies. The one-time notice banner is model-dynamic and already renders the correct slug/cap. - hermes_cli/config.py, agent/agent_init.py: refresh the autoraise comments/notice to mention the 5.6 family. - hermes_cli/codex_models.py: add the -pro variants to DEFAULT_CODEX_MODELS + forward-compat so ChatGPT-OAuth (openai-codex) Pro users see the full family in /model, not just the base tiers. Supersedes the earlier commit's note that 5.6 was intentionally kept out of the codex catalog: the slugs are confirmed routable (OpenRouter live + codex backend), so they belong there like every other codex-capable gpt-5.x slug. E2E verified across all 6 slugs: direct-API ctx 1.05M, codex ctx 272K, pricing reachable from openai + openai-api routes, codex compaction override 0.85 (and None on direct-API + when opted out), present in openai-api picker + codex catalog, /model gpt resolves to sol on both native routes. Guard tests added for the compaction route matrix. --- agent/agent_init.py | 4 +- agent/auxiliary_client.py | 43 +++++++++++------- hermes_cli/codex_models.py | 9 +++- hermes_cli/config.py | 23 +++++----- tests/hermes_cli/test_gpt56_registration.py | 50 +++++++++++++++++++++ 5 files changed, 98 insertions(+), 31 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 495260d21886..f1666a1aafb4 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -77,8 +77,8 @@ def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str: include the exact opt-back-out command. """ model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1] - # gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5 family is - # capped at 272K by the Codex OAuth backend. + # gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5/5.6 family + # is capped at 272K by the Codex OAuth backend. cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K" from_pct = int(round(autoraise["from"] * 100)) to_pct = int(round(autoraise["to"] * 100)) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index d55f7df4fcdb..b1d9a8135e6f 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -314,15 +314,18 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool: return bare == "trinity-large-thinking" -# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.4/5.5. -# The raw OpenAI API and OpenRouter expose 1.05M for the same slug, but the -# Codex backend hard-caps at 272K (verified live: a ~330K-token request to +# Context window enforced by ChatGPT's Codex OAuth backend for the +# gpt-5.4 / gpt-5.5 / gpt-5.6 families. The raw OpenAI API and OpenRouter +# expose 1.05M for the same slugs, but the Codex backend hard-caps at 272K +# (verified live for 5.4/5.5: a ~330K-token request to # chatgpt.com/backend-api/codex/responses is rejected with -# ``context_length_exceeded`` while ~250K succeeds). With a 272K ceiling the -# default 50% compaction trigger fires at ~136K — wasteful, since the model -# can hold far more raw context before summarization actually buys anything. -# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.4/ -# gpt-5.5 sessions use the window they actually have. +# ``context_length_exceeded`` while ~250K succeeds; gpt-5.6 shares the same +# 272K Codex cap — see _CODEX_OAUTH_CONTEXT_FALLBACK in model_metadata.py). +# With a 272K ceiling the default 50% compaction trigger fires at ~136K — +# wasteful, since the model can hold far more raw context before +# summarization actually buys anything. We raise the trigger to 85% (~231K) +# on this exact route so Codex gpt-5.4 / gpt-5.5 / gpt-5.6 sessions use the +# window they actually have. _CODEX_GPT54_GPT55_COMPACTION_THRESHOLD = 0.85 # gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a @@ -336,14 +339,16 @@ _CODEX_SPARK_COMPACTION_THRESHOLD = 0.70 def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool: - """True for gpt-5.4 / gpt-5.5 on the ChatGPT Codex OAuth backend. + """True for gpt-5.4 / gpt-5.5 / gpt-5.6 on the ChatGPT Codex OAuth backend. Matches only the Codex OAuth route (provider ``openai-codex``), not the direct OpenAI API, OpenRouter, or GitHub Copilot paths — those expose a larger context window for the same slug and must keep the user's default - compaction threshold. ``gpt-5.4-pro`` / ``gpt-5.5-pro`` and dated snapshots - are matched via prefix so the override tracks both 272K-capped families - without re-listing every variant. + compaction threshold. ``-pro`` variants and dated snapshots are matched + via prefix so the override tracks every 272K-capped family (5.4, 5.5, + 5.6 sol/terra/luna incl. their ``-pro`` modes) without re-listing every + variant. (Name kept for backward compatibility with the + ``compression.codex_gpt55_autoraise`` config key.) """ prov = (provider or "").strip().lower() if prov != "openai-codex": @@ -356,6 +361,9 @@ def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = Non or bare == "gpt-5.5" or bare.startswith("gpt-5.5-") or bare.startswith("gpt-5.5.") + or bare == "gpt-5.6" + or bare.startswith("gpt-5.6-") + or bare.startswith("gpt-5.6.") ) @@ -410,11 +418,12 @@ def _compression_threshold_for_model( Per-model/route overrides: - Arcee Trinity Large Thinking → 0.75 (preserve reasoning context). - - gpt-5.4 / gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps - both families at 272K and the default 50% trigger would compact at - ~136K. Gated by ``allow_codex_gpt55_autoraise`` (historical config-key - name kept for backward compatibility) so the user can opt back down to - the global default (the caller passes the config flag through here). + - gpt-5.4 / gpt-5.5 / gpt-5.6 on the Codex OAuth route → 0.85, because + Codex caps all three families at 272K and the default 50% trigger + would compact at ~136K. Gated by ``allow_codex_gpt55_autoraise`` + (historical config-key name kept for backward compatibility) so the + user can opt back down to the global default (the caller passes the + config flag through here). - gpt-5.3-codex-spark on the Codex OAuth route → 0.70, because the model has a native 128K window and the default 50% trigger would compact at ~64K — wasting half the usable context. Not gated by the gpt-5.5 diff --git a/hermes_cli/codex_models.py b/hermes_cli/codex_models.py index bd7b2d78e8be..a56cddf73a20 100644 --- a/hermes_cli/codex_models.py +++ b/hermes_cli/codex_models.py @@ -12,10 +12,14 @@ import os logger = logging.getLogger(__name__) DEFAULT_CODEX_MODELS: List[str] = [ - # GPT-5.6 series (Sol/Terra/Luna) — GA 2026-07-09 (previewed 2026-06-26). + # GPT-5.6 series (Sol/Terra/Luna + -pro high-effort modes) — GA 2026-07-09 + # (previewed 2026-06-26). "gpt-5.6-sol", + "gpt-5.6-sol-pro", "gpt-5.6-terra", + "gpt-5.6-terra-pro", "gpt-5.6-luna", + "gpt-5.6-luna-pro", "gpt-5.5", "gpt-5.4-mini", "gpt-5.4", @@ -49,8 +53,11 @@ DEFAULT_CODEX_MODELS: List[str] = [ _FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [ ("gpt-5.6-sol", ("gpt-5.5", "gpt-5.4")), + ("gpt-5.6-sol-pro", ("gpt-5.5", "gpt-5.4")), ("gpt-5.6-terra", ("gpt-5.5", "gpt-5.4")), + ("gpt-5.6-terra-pro", ("gpt-5.5", "gpt-5.4")), ("gpt-5.6-luna", ("gpt-5.5", "gpt-5.4")), + ("gpt-5.6-luna-pro", ("gpt-5.5", "gpt-5.4")), ("gpt-5.5", ("gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex")), ("gpt-5.4-mini", ("gpt-5.3-codex",)), ("gpt-5.4", ("gpt-5.3-codex",)), diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7c83c6f167aa..678cdb869af8 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1423,17 +1423,18 @@ DEFAULT_CONFIG = { # True if you'd rather pause than silently lose # context turns when your aux model is flaky. "codex_gpt55_autoraise": True, # Historical key name kept for compatibility. - # When True, gpt-5.4 / gpt-5.5 on the ChatGPT Codex - # OAuth route raise their compaction trigger to 85% - # (vs the global `threshold` above). Codex hard-caps - # both families at a 272K window, so the default 50% - # would compact at ~136K and waste half the usable - # context. Set to False to opt back down to the global - # threshold (e.g. 0.50) for those Codex sessions. - # Only this exact route is affected — gpt-5.4 / 5.5 - # on OpenAI's direct API, OpenRouter, and Copilot keep - # the global threshold regardless. - "codex_gpt55_autoraise_notice": True, # Display the one-time Codex gpt-5.4/5.5 + # When True, gpt-5.4 / gpt-5.5 / gpt-5.6 on the + # ChatGPT Codex OAuth route raise their compaction + # trigger to 85% (vs the global `threshold` above). + # Codex hard-caps these families at a 272K window, so + # the default 50% would compact at ~136K and waste half + # the usable context. Set to False to opt back down to + # the global threshold (e.g. 0.50) for those Codex + # sessions. Only this exact route is affected — + # gpt-5.4 / 5.5 / 5.6 on OpenAI's direct API, + # OpenRouter, and Copilot keep the global threshold + # regardless. + "codex_gpt55_autoraise_notice": True, # Display the one-time Codex gpt-5.4/5.5/5.6 # autoraise banner. Set False to keep the # 85% threshold autoraise but suppress the # user-facing notice in CLI/gateway output. diff --git a/tests/hermes_cli/test_gpt56_registration.py b/tests/hermes_cli/test_gpt56_registration.py index 9d63992ecaab..5799fa083585 100644 --- a/tests/hermes_cli/test_gpt56_registration.py +++ b/tests/hermes_cli/test_gpt56_registration.py @@ -81,3 +81,53 @@ class TestGpt56PricingRoute: _OFFICIAL_DOCS_PRICING[("openai", f"{base}-pro")] is _OFFICIAL_DOCS_PRICING[("openai", base)] ), base + + +class TestGpt56CodexCompaction: + """Codex OAuth caps the whole gpt-5.6 family at 272K, same as 5.4/5.5, so + the compaction auto-raise (0.85) must fire for every 5.6 variant on the + openai-codex route and NOT on the direct-API/OpenRouter routes.""" + + def test_autoraise_applies_to_all_56_on_codex(self): + from agent.auxiliary_client import _compression_threshold_for_model + + for slug in ( + "gpt-5.6-sol", + "gpt-5.6-sol-pro", + "gpt-5.6-terra", + "gpt-5.6-terra-pro", + "gpt-5.6-luna", + "gpt-5.6-luna-pro", + ): + assert ( + _compression_threshold_for_model(slug, provider="openai-codex") + == 0.85 + ), slug + + def test_no_autoraise_on_direct_api_route(self): + from agent.auxiliary_client import _compression_threshold_for_model + + # Direct OpenAI API / OpenRouter expose the full 1.05M window, so the + # 272K-cap override must NOT apply there. + assert ( + _compression_threshold_for_model("gpt-5.6-sol", provider="openai") + is None + ) + assert ( + _compression_threshold_for_model( + "openai/gpt-5.6-sol", provider="openrouter" + ) + is None + ) + + def test_autoraise_respects_opt_out(self): + from agent.auxiliary_client import _compression_threshold_for_model + + assert ( + _compression_threshold_for_model( + "gpt-5.6-sol", + provider="openai-codex", + allow_codex_gpt55_autoraise=False, + ) + is None + ) From 111544d544d6cf6efed9875e116f2daeb76a1211 Mon Sep 17 00:00:00 2001 From: Kshitij Kapoor <kshitijk4poor@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:41:03 +0530 Subject: [PATCH 321/610] test(codex-picker): raise max_models so count invariant survives catalog growth Adding the 6 gpt-5.6 slugs to DEFAULT_CODEX_MODELS grew the curated codex catalog to 11, above the test's max_models=10 cap. That truncated the picker list to 10 while total_models reported 11, breaking the total_models == len(models) assertion. The cap was an implicit change-detector on catalog size; raise it to 100 so the list is never truncated and the count-consistency invariant stays meaningful as new gpt-5.x slugs land. --- tests/hermes_cli/test_codex_cli_model_picker.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/hermes_cli/test_codex_cli_model_picker.py b/tests/hermes_cli/test_codex_cli_model_picker.py index 3968437a8561..b0dd1c6435ac 100644 --- a/tests/hermes_cli/test_codex_cli_model_picker.py +++ b/tests/hermes_cli/test_codex_cli_model_picker.py @@ -95,7 +95,10 @@ def test_codex_picker_uses_live_codex_catalog(hermes_auth_only_env, tmp_path, mo providers = list_authenticated_providers( current_provider="openai-codex", - max_models=10, + # High cap so the curated catalog is never truncated — the assertion + # below checks count consistency, which only holds when max_models + # exceeds the catalog size (it grows as new gpt-5.x slugs land). + max_models=100, ) codex = next(p for p in providers if p["slug"] == "openai-codex") From 5829fe1378eb1083c77c02198016f052b886abb9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Wed, 8 Jul 2026 22:23:38 -0500 Subject: [PATCH 322/610] fix(kanban): failure diagnostics exempt done/archived tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A manual done (dashboard/desktop drag) runs complete_task but ends no run, so a trailing crashed/crashed run history never gains the 'completed' outcome that breaks the repeated_crashes streak — the card kept flagging "needs attention" forever after being finished. repeated_failures had the same hole via a stale counter. Terminal statuses are now exempt from both: done means done; the history stays on the event log for audit. Regression test included. --- hermes_cli/kanban_diagnostics.py | 15 +++++++++++++++ tests/hermes_cli/test_kanban_diagnostics.py | 20 +++++++++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index 81da4c191561..1ee680f1e401 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -530,7 +530,14 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: Accepts the legacy ``spawn_failure_threshold`` config key for back-compat. + + Terminal statuses are exempt: a done/archived card has nothing left + to retry, so a lingering failure streak is history, not a signal. + (``complete_task`` resets the counter, but a manual done — e.g. a + dashboard drag — ends no run and used to leave the flag stuck.) """ + if _task_field(task, "status") in ("done", "archived"): + return [] threshold = _positive_int(cfg.get( "failure_threshold", cfg.get("spawn_failure_threshold", 3), @@ -649,7 +656,15 @@ def _rule_repeated_crashes(task, events, runs, now, cfg) -> list[Diagnostic]: total failures) so the operator gets a crash-specific heads-up before the unified rule kicks in. Suppresses itself when the unified rule is also about to fire, to avoid double-flagging. + + Terminal statuses are exempt for the same reason as + ``repeated_failures`` — with one extra wrinkle: this rule reads run + history, and a manual done (dashboard drag) appends no ``completed`` + run to break the crash streak, so the flag was permanent (#kanban + desktop dogfood). Done means done. """ + if _task_field(task, "status") in ("done", "archived"): + return [] failure_threshold = int(cfg.get( "failure_threshold", cfg.get("spawn_failure_threshold", 3), diff --git a/tests/hermes_cli/test_kanban_diagnostics.py b/tests/hermes_cli/test_kanban_diagnostics.py index 2de4933dc634..c040bbdfeef7 100644 --- a/tests/hermes_cli/test_kanban_diagnostics.py +++ b/tests/hermes_cli/test_kanban_diagnostics.py @@ -272,6 +272,16 @@ def test_repeated_crashes_escalates_on_many_crashes(): assert diags[0].severity == "critical" +def test_failure_rules_exempt_terminal_statuses(): + # A manual done (dashboard drag) ends no run, so the trailing crash + # streak survives in run history — but done means done: neither + # failure rule may keep flagging a terminal card. + runs = [_run(outcome="crashed", run_id=1), _run(outcome="crashed", run_id=2)] + for status in ("done", "archived"): + task = _task(status=status, assignee="crashy", consecutive_failures=3) + assert kd.compute_task_diagnostics(task, [], runs) == [] + + def test_stuck_in_blocked_fires_past_threshold(): now = int(time.time()) task = _task(status="blocked") @@ -368,15 +378,19 @@ def test_repeated_crashes_truncates_huge_tracebacks(): def test_diagnostics_sorted_critical_first(): """A task with both a critical (many spawn failures) and a warning - (prose phantoms) diagnostic should list the critical one first.""" - task = _task(status="done", consecutive_failures=10, + (prose phantoms) diagnostic should list the critical one first. + + Status must be non-terminal: done/archived are exempt from the + failure rules (done means done). ``now=300`` keeps the synthetic + timestamps from tripping stranded_in_ready — same dodge as above.""" + task = _task(status="ready", consecutive_failures=10, last_failure_error="nope") events = [ _event("completed", ts=100, summary="referenced t_missing"), _event("suspected_hallucinated_references", ts=101, phantom_refs=["t_missing11"]), ] - diags = kd.compute_task_diagnostics(task, events, []) + diags = kd.compute_task_diagnostics(task, events, [], now=300) kinds = [d.kind for d in diags] assert kinds[0] == "repeated_failures" # critical assert "prose_phantom_refs" in kinds From e87c495dc2d827dbc9bc1df86f4e75673e11ed24 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Thu, 9 Jul 2026 09:02:04 -0500 Subject: [PATCH 323/610] =?UTF-8?q?fix(kanban):=20spawn=20workers=20headle?= =?UTF-8?q?ss=20=E2=80=94=20TUI=20can=20never=20eat=20a=20worker=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An inherited HERMES_TUI=1 or a `display.interface: tui` config default sent kanban workers into the Ink TUI, whose no-TTY bail-out exits 0 without doing the task — every attempt ended in "protocol violation". Two layers: - _default_spawn pins `--cli` (highest-precedence interface flag) and strips HERMES_TUI from the child env (covers older builds on PATH). - _resolve_use_tui gates ambient TUI prefs (env/config) behind a real TTY; an explicit --tui still wins so the informative bail-out stays reachable. --- hermes_cli/kanban_db.py | 9 ++++ hermes_cli/main.py | 26 ++++++++++-- .../test_default_interface_resolution.py | 42 ++++++++++++++++++- .../test_kanban_worker_spawn_toolsets.py | 36 ++++++++++++++++ 4 files changed, 108 insertions(+), 5 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 6150b141537b..4a958d6ae7ec 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -7768,9 +7768,18 @@ def _default_spawn( # attributed correctly regardless of how the child loads config. env["HERMES_PROFILE"] = profile_arg + # A worker must NEVER boot the interactive TUI: an inherited HERMES_TUI=1 + # or a `display.interface: tui` in the profile's config would send the + # quiet chat run into the Ink TUI, whose no-TTY bail-out exits 0 without + # doing the task → "protocol violation" on every attempt. `--cli` is the + # highest-precedence interface override; dropping the env var covers + # older hermes builds on PATH that predate the flag's precedence. + env.pop("HERMES_TUI", None) + cmd = [ *_resolve_hermes_argv(), "-p", profile_arg, + "--cli", # Worker subprocesses switch to a profile-scoped HERMES_HOME above, # so they see that profile's shell-hook allowlist instead of the # dispatcher's root allowlist. Pass --accept-hooks explicitly so diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f450a76666f3..2c5b03616627 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2193,16 +2193,34 @@ def _resolve_use_tui(args) -> bool: Precedence (highest first): 1. ``--cli`` flag → always classic REPL - 2. ``--tui`` flag / ``HERMES_TUI=1`` → always TUI - 3. ``display.interface`` config value ("cli" | "tui") - 4. default → classic REPL + 2. ``--tui`` flag → always TUI (explicit ask) + 3. no TTY → always classic (ambient prefs don't apply) + 4. ``HERMES_TUI=1`` env → TUI + 5. ``display.interface`` config value ("cli" | "tui") + 6. default → classic REPL Explicit flags always win over config so muscle memory and scripts keep working regardless of the configured default. + + The TTY gate (3) is load-bearing: ambient TUI preferences (env var or + config default) must never hijack a NON-interactive invocation. Kanban + workers, cron jobs, and pipelines run ``hermes … chat -q`` with stdout + on a pipe; booting the Ink TUI there hits its no-TTY bail-out, which + prints a resume hint and exits 0 — a kanban worker then dies with + "exited cleanly without calling kanban_complete — protocol violation" + on every attempt (found dogfooding the desktop kanban board). A user + who *explicitly* passes ``--tui`` still gets the informative bail-out. """ if getattr(args, "cli", False): return False - if getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1": + if getattr(args, "tui", False): + return True + try: + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return False + except Exception: + return False + if os.environ.get("HERMES_TUI") == "1": return True try: from hermes_cli.config import load_config diff --git a/tests/hermes_cli/test_default_interface_resolution.py b/tests/hermes_cli/test_default_interface_resolution.py index c04f8093b29a..ce7c5d8b55c3 100644 --- a/tests/hermes_cli/test_default_interface_resolution.py +++ b/tests/hermes_cli/test_default_interface_resolution.py @@ -5,10 +5,18 @@ flip ``display.interface: tui`` in config.yaml to make the modern Ink TUI the default for bare ``hermes`` / ``hermes chat``. Explicit flags always win: --cli forces the classic REPL (highest precedence) - --tui / HERMES_TUI=1 forces the TUI + --tui forces the TUI + (no TTY) forces the classic REPL — ambient prefs don't apply + HERMES_TUI=1 the env default display.interface the configured default (unset) classic REPL +The no-TTY gate exists because ambient TUI preferences must never hijack +non-interactive invocations: kanban workers / cron / pipelines run +``hermes … chat -q`` on a pipe, and the TUI's no-TTY bail-out exits 0 +without doing the work (a kanban worker then dies with "protocol +violation" on every attempt). + These tests pin that precedence at every layer that makes the decision: * ``_resolve_use_tui(args)`` — the canonical args-aware resolver used by @@ -46,6 +54,14 @@ def _args(**kw): return SimpleNamespace(**kw) +def _fake_tty(monkeypatch, interactive: bool): + """Pin stdin/stdout TTY-ness — pytest's capture is never a real TTY.""" + import sys as _sys + + monkeypatch.setattr(_sys.stdin, "isatty", lambda: interactive, raising=False) + monkeypatch.setattr(_sys.stdout, "isatty", lambda: interactive, raising=False) + + def _patch_config(monkeypatch, interface): import hermes_cli.config as cfg @@ -73,19 +89,23 @@ class TestResolveUseTui: def test_env_beats_config_cli(self, monkeypatch): _patch_config(monkeypatch, "cli") + _fake_tty(monkeypatch, True) monkeypatch.setenv("HERMES_TUI", "1") assert m._resolve_use_tui(_args()) is True def test_config_tui_with_no_flags(self, monkeypatch): _patch_config(monkeypatch, "tui") + _fake_tty(monkeypatch, True) assert m._resolve_use_tui(_args()) is True def test_config_cli_is_default(self, monkeypatch): _patch_config(monkeypatch, "cli") + _fake_tty(monkeypatch, True) assert m._resolve_use_tui(_args()) is False def test_interface_value_is_case_insensitive(self, monkeypatch): _patch_config(monkeypatch, "TUI") + _fake_tty(monkeypatch, True) assert m._resolve_use_tui(_args()) is True def test_load_config_failure_falls_back_to_cli(self, monkeypatch): @@ -95,8 +115,28 @@ class TestResolveUseTui: raise RuntimeError("config unreadable") monkeypatch.setattr(cfg, "load_config", boom) + _fake_tty(monkeypatch, True) assert m._resolve_use_tui(_args()) is False + # ── the no-TTY gate: ambient prefs never hijack non-interactive runs ──── + def test_no_tty_blocks_env_tui(self, monkeypatch): + _patch_config(monkeypatch, "cli") + _fake_tty(monkeypatch, False) + monkeypatch.setenv("HERMES_TUI", "1") + assert m._resolve_use_tui(_args()) is False + + def test_no_tty_blocks_config_tui(self, monkeypatch): + _patch_config(monkeypatch, "tui") + _fake_tty(monkeypatch, False) + assert m._resolve_use_tui(_args()) is False + + def test_explicit_tui_flag_survives_no_tty(self, monkeypatch): + # An explicit --tui is the user's own ask — keep the informative + # no-TTY bail-out instead of silently swapping interfaces. + _patch_config(monkeypatch, "cli") + _fake_tty(monkeypatch, False) + assert m._resolve_use_tui(_args(tui=True)) is True + # --------------------------------------------------------------------------- # _wants_tui_early — dependency-free early resolver diff --git a/tests/hermes_cli/test_kanban_worker_spawn_toolsets.py b/tests/hermes_cli/test_kanban_worker_spawn_toolsets.py index 7469a7bf0577..da9b6b957b2a 100644 --- a/tests/hermes_cli/test_kanban_worker_spawn_toolsets.py +++ b/tests/hermes_cli/test_kanban_worker_spawn_toolsets.py @@ -89,6 +89,42 @@ agent: assert required in pinned +def test_default_spawn_never_boots_the_tui(monkeypatch, tmp_path): + """Workers are headless: an inherited HERMES_TUI=1 (or a TUI-default + config) must not send the quiet chat run into the Ink TUI, whose no-TTY + bail-out exits 0 without doing the task — every attempt then ends in + "protocol violation". The spawn pins --cli (highest-precedence interface + flag) and strips HERMES_TUI from the child env.""" + root = tmp_path / ".hermes" + (root / "profiles" / "elias").mkdir(parents=True) + root.joinpath("config.yaml").write_text("display:\n interface: tui\n", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(root)) + monkeypatch.setenv("HERMES_TUI", "1") + + from hermes_cli import kanban_db as kb + + monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: ["hermes"]) + + captured = {} + + class FakeProc: + pid = 4243 + + def fake_popen(cmd, *args, **kwargs): + captured["cmd"] = list(cmd) + captured["env"] = dict(kwargs.get("env") or {}) + return FakeProc() + + monkeypatch.setattr(subprocess, "Popen", fake_popen) + + workspace = tmp_path / "workspace" + workspace.mkdir() + kb._default_spawn(_make_task(kb, assignee="elias"), str(workspace)) + + assert "--cli" in captured["cmd"] + assert "HERMES_TUI" not in captured["env"] + + def test_resolve_worker_cli_toolsets_uses_profile_home_not_parent_config(monkeypatch, tmp_path): root = tmp_path / ".hermes" profile = root / "profiles" / "elias" From aea570db4e21c2012efce042b790980fb6a1978d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Thu, 9 Jul 2026 15:59:50 -0500 Subject: [PATCH 324/610] fix(kanban): clear failure/crash diagnostics while a retry is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A retried task (→ running) kept showing "crashed Nx": the in-flight run has no outcome yet, so the trailing crash scan skipped it and kept counting the prior streak, and the consecutive_failures counter lingers. Exempt `running` from both repeated_failures and repeated_crashes so a fresh attempt clears the banner until it itself resolves (re-fires if the new run also fails). --- hermes_cli/kanban_diagnostics.py | 15 +++++++++++++-- tests/hermes_cli/test_kanban_diagnostics.py | 11 +++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index 1ee680f1e401..8173f1e33388 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -535,8 +535,14 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: to retry, so a lingering failure streak is history, not a signal. (``complete_task`` resets the counter, but a manual done — e.g. a dashboard drag — ends no run and used to leave the flag stuck.) + + A fresh attempt in flight (``running``) is also exempt: retrying a + task should clear the stale failure banner until this attempt also + resolves. Otherwise a card that's actively trying again still shows + "failed Nx", which reads as a current failure. It re-fires if the new + run fails too (status leaves ``running`` with a recorded outcome). """ - if _task_field(task, "status") in ("done", "archived"): + if _task_field(task, "status") in ("done", "archived", "running"): return [] threshold = _positive_int(cfg.get( "failure_threshold", @@ -662,8 +668,13 @@ def _rule_repeated_crashes(task, events, runs, now, cfg) -> list[Diagnostic]: history, and a manual done (dashboard drag) appends no ``completed`` run to break the crash streak, so the flag was permanent (#kanban desktop dogfood). Done means done. + + ``running`` is exempt too: a fresh attempt is in flight, and its + in-flight run (no outcome yet) doesn't break the trailing crash scan, + so a retried card kept showing "crashed Nx" over an active run. The + banner re-fires if the new attempt also crashes. """ - if _task_field(task, "status") in ("done", "archived"): + if _task_field(task, "status") in ("done", "archived", "running"): return [] failure_threshold = int(cfg.get( "failure_threshold", diff --git a/tests/hermes_cli/test_kanban_diagnostics.py b/tests/hermes_cli/test_kanban_diagnostics.py index c040bbdfeef7..474cfd07611f 100644 --- a/tests/hermes_cli/test_kanban_diagnostics.py +++ b/tests/hermes_cli/test_kanban_diagnostics.py @@ -282,6 +282,17 @@ def test_failure_rules_exempt_terminal_statuses(): assert kd.compute_task_diagnostics(task, [], runs) == [] +def test_failure_rules_exempt_running_retry(): + # Retrying a task (→ running) puts a fresh attempt in flight; its + # in-flight run (no outcome) doesn't break the trailing crash scan, + # so the past streak used to keep flagging over an active retry. + # A running card must clear the failure/crash banner until this + # attempt itself resolves. + runs = [_run(outcome="crashed", run_id=1), _run(outcome="crashed", run_id=2)] + task = _task(status="running", assignee="crashy", consecutive_failures=3) + assert kd.compute_task_diagnostics(task, [], runs) == [] + + def test_stuck_in_blocked_fires_past_threshold(): now = int(time.time()) task = _task(status="blocked") From 77db9d6bf384c71e145c556286386887dd3e5a27 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Thu, 9 Jul 2026 16:05:21 -0500 Subject: [PATCH 325/610] fix(kanban): explicit re-queue bypasses the recent-success respawn guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging a task done→ready did nothing: the respawn guard saw a run that completed within the success window and deferred forever, unable to tell a deliberate operator re-run from a status flap. Now a re-queue event (status change, promote, unblock, reclaim) AFTER the completion bypasses the recent_success guard, so an explicit done→ready runs again. --- hermes_cli/kanban_db.py | 30 ++++++++++++++++++++++++------ tests/hermes_cli/test_kanban_db.py | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 4a958d6ae7ec..518e74eb0647 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -6789,7 +6789,9 @@ def check_respawn_guard(conn: sqlite3.Connection, task_id: str) -> Optional[str] ``"recent_success"`` A completed run exists within ``_RESPAWN_GUARD_SUCCESS_WINDOW`` seconds. Useful work already succeeded for this task; wait for - human review rather than immediately re-spawning. + human review rather than immediately re-spawning. Bypassed when an + explicit re-queue event (status change, promote, unblock, reclaim) + arrives AFTER that completion — that's a deliberate re-run request. ``"active_pr"`` A GitHub PR URL appears in a recent task comment (within @@ -6852,13 +6854,29 @@ def check_respawn_guard(conn: sqlite3.Connection, task_id: str) -> Optional[str] return "blocker_auth" # 3. Completed run within guard window — proof of recent success. + # Exception: an explicit re-queue AFTER that success (an operator + # dragging done→ready, a dependency re-promotion, an unblock, a + # reclaim) is a deliberate "run it again" — honor it instead of + # deferring. Without this, a manual done→ready just sits there, + # silently held by the guard, until the window elapses. cutoff = now - _RESPAWN_GUARD_SUCCESS_WINDOW - if conn.execute( - "SELECT id FROM task_runs " - "WHERE task_id = ? AND outcome = 'completed' AND ended_at >= ?", + recent_completed = conn.execute( + "SELECT ended_at FROM task_runs " + "WHERE task_id = ? AND outcome = 'completed' AND ended_at >= ? " + "ORDER BY ended_at DESC LIMIT 1", (task_id, cutoff), - ).fetchone(): - return "recent_success" + ).fetchone() + if recent_completed: + completed_at = int(recent_completed["ended_at"] or 0) + requeued_after = conn.execute( + "SELECT 1 FROM task_events " + "WHERE task_id = ? AND created_at >= ? " + "AND kind IN ('status', 'promoted', 'unblocked', 'reclaimed') " + "LIMIT 1", + (task_id, completed_at), + ).fetchone() + if not requeued_after: + return "recent_success" # 4. GitHub PR URL in a recent comment — prior worker already opened a PR. pr_cutoff = now - _RESPAWN_GUARD_PR_WINDOW diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 05de4a913eb2..a9e61f41690a 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -1879,6 +1879,30 @@ def test_respawn_guard_recent_success(kanban_home): assert reason == "recent_success" +def test_respawn_guard_recent_success_bypassed_by_requeue(kanban_home): + """An explicit re-queue after a recent success (operator done->ready, + promote, unblock, reclaim) is a deliberate re-run and must bypass the + recent_success guard — otherwise a manual done->ready just sits there + until the window elapses.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="rerun-me", assignee="alice") + now = int(time.time()) + conn.execute( + "INSERT INTO task_runs (task_id, status, outcome, started_at, ended_at) " + "VALUES (?, 'done', 'completed', ?, ?)", + (t, now - 120, now - 60), + ) + # Baseline: a recent completion defers the respawn. + assert kb.check_respawn_guard(conn, t) == "recent_success" + # Operator drags done -> ready: a 'status' event after completion. + conn.execute( + "INSERT INTO task_events (task_id, kind, created_at) " + "VALUES (?, 'status', ?)", + (t, now - 10), + ) + assert kb.check_respawn_guard(conn, t) is None + + def test_respawn_guard_stale_success_not_guarded(kanban_home): """A completed run outside the guard window does not block re-spawn.""" with kb.connect() as conn: From b06e2f846cf6631e9f679d81d66be3c5421ea355 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Thu, 9 Jul 2026 16:13:28 -0500 Subject: [PATCH 326/610] =?UTF-8?q?fix(kanban):=20no-TTY=20gate=20in=20=5F?= =?UTF-8?q?wants=5Ftui=5Fearly=20=E2=80=94=20the=20actual=20worker-crash?= =?UTF-8?q?=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier fix gated _resolve_use_tui, but the EARLY launcher (_wants_tui_early) decides TUI from display.interface before cmd_chat runs — so a `display.interface: tui` default still booted the Ink UI for headless spawns (kanban workers), whose no-TTY bail-out exits 0 → "protocol violation". Gate the early resolver on a real TTY: headless stdio never boots the TUI regardless of config; explicit --tui still does. --- hermes_cli/main.py | 17 ++++++++++++++++- .../test_default_interface_resolution.py | 16 +++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 2c5b03616627..ddaa3ee31989 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -148,7 +148,17 @@ def _wants_tui_early(argv: "list[str] | None" = None) -> bool: """Earliest TUI decision, usable before argparse/config imports. Precedence: explicit ``--cli`` wins (forces classic REPL), then - ``--tui``/``HERMES_TUI=1``, then ``display.interface`` in config. + explicit ``--tui``/``HERMES_TUI=1``, then a real-TTY gate (a + non-interactive stdio can't host the Ink UI, so ambient config never + boots it there), then ``display.interface`` in config. + + The TTY gate is load-bearing for headless spawners — kanban workers, + cron jobs, pipes run ``hermes … chat -q`` with stdio on a pipe. This + is the earliest launch decision (it runs before ``cmd_chat`` / + ``_resolve_use_tui``), so a ``display.interface: tui`` default used to + boot the TUI here — whose no-TTY bail-out exits 0 without doing the + task → "protocol violation" on every attempt. An explicit ``--tui`` + still reaches the informative bail-out. """ if argv is None: argv = sys.argv[1:] @@ -156,6 +166,11 @@ def _wants_tui_early(argv: "list[str] | None" = None) -> bool: return False if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv: return True + try: + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return False + except Exception: + return False return _config_default_interface_early() == "tui" diff --git a/tests/hermes_cli/test_default_interface_resolution.py b/tests/hermes_cli/test_default_interface_resolution.py index ce7c5d8b55c3..85208638ec6c 100644 --- a/tests/hermes_cli/test_default_interface_resolution.py +++ b/tests/hermes_cli/test_default_interface_resolution.py @@ -153,10 +153,24 @@ class TestWantsTuiEarly: return _make - def test_config_tui_bare_argv(self, home_with_interface): + def test_config_tui_bare_argv(self, home_with_interface, monkeypatch): home_with_interface("tui") + _fake_tty(monkeypatch, True) # config-tui only applies on a real TTY assert m._wants_tui_early([]) is True + def test_no_tty_blocks_config_tui(self, home_with_interface, monkeypatch): + # Headless (worker/cron/pipe): ambient config-tui must not boot the + # Ink UI in the earliest launch decision — that's the crash the + # kanban worker hit before the gate existed. + home_with_interface("tui") + _fake_tty(monkeypatch, False) + assert m._wants_tui_early([]) is False + + def test_explicit_tui_flag_survives_no_tty(self, home_with_interface, monkeypatch): + home_with_interface("cli") + _fake_tty(monkeypatch, False) + assert m._wants_tui_early(["--tui"]) is True + def test_cli_flag_overrides_config_tui(self, home_with_interface): home_with_interface("tui") assert m._wants_tui_early(["--cli"]) is False From fe25806a6bfa8318d56a3e1a4065abf04d619fb2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:36:03 -0700 Subject: [PATCH 327/610] fix(config): retain last-known-good config when config.yaml fails to parse (#60591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port from openai/codex#31188: a parse failure in a policy-bearing config file must not silently replace the effective policy with an empty/default one. Codex's load_exec_policy_with_warning replaced the whole exec policy with Policy::empty() when a .rules file failed to parse, silently dropping managed prompt/forbidden rules; the fix preserves the managed policy while still warning. Hermes had the same bug shape in load_config(): a YAML parse error made _load_config_impl() fall through to DEFAULT_CONFIG, dropping every user override — including approvals.deny rules, which are documented to block commands even under --yolo. In a long-running gateway, a user mid-editing config.yaml into broken YAML silently disarmed their own deny rules on the next load. Now, when the process has a last successfully loaded config for that path (_LAST_EXPANDED_CONFIG_BY_PATH), a parse failure keeps serving it (cached under the corrupt file's signature so the broken file isn't re-parsed) and the warning says edits are being ignored until the YAML is fixed. Fresh processes with no last-known-good keep the existing DEFAULT_CONFIG fallback and warning. E2E-verified: deny rule 'curl*evil.com*' still blocks after mid-process corruption; fixed file reloads normally; fresh-process fallback unchanged. --- hermes_cli/config.py | 68 +++++++++++++++++++++--- tests/hermes_cli/test_config.py | 93 +++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 8 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 678cdb869af8..153fc5fb6061 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -93,7 +93,9 @@ def _backup_corrupt_config(config_path: Path) -> Optional[Path]: return None -def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None: +def _warn_config_parse_failure( + config_path: Path, exc: Exception, *, fallback: str = "defaults" +) -> None: """Surface a config.yaml parse failure to user, log, and stderr. A YAML parse error in ``~/.hermes/config.yaml`` causes ``load_config()`` @@ -110,6 +112,11 @@ def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None: timestamped ``.bak`` (best-effort) so the user's recoverable content survives any later rewrite of ``config.yaml`` by the setup wizard or ``hermes config set``. + + ``fallback`` selects the message wording: ``"defaults"`` (fresh process, + nothing else to serve) or ``"last-known-good"`` (in-process retention of + the previously loaded config — see the codex#31188 port in + ``_load_config_impl``). """ try: st = config_path.stat() @@ -122,12 +129,19 @@ def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None: backup_path = _backup_corrupt_config(config_path) - msg = ( - f"Failed to parse {config_path}: {exc}. " - f"Falling back to default config — every user override " - f"(auxiliary providers, fallback chain, model settings) is being IGNORED. " - f"Fix the YAML and restart." - ) + if fallback == "last-known-good": + msg = ( + f"Failed to parse {config_path}: {exc}. " + f"Keeping the previously loaded config for this process — " + f"edits to config.yaml are being IGNORED until the YAML is fixed." + ) + else: + msg = ( + f"Failed to parse {config_path}: {exc}. " + f"Falling back to default config — every user override " + f"(auxiliary providers, fallback chain, model settings) is being IGNORED. " + f"Fix the YAML and restart." + ) if backup_path is not None: msg += f" A copy of the corrupted file was saved to {backup_path}." logger.warning(msg) @@ -6936,7 +6950,45 @@ def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]: config = _deep_merge(config, user_config) except Exception as e: - _warn_config_parse_failure(config_path, e) + # Last-known-good fallback (port of openai/codex#31188's + # invariant: a parse failure in a policy/config file must not + # silently replace the effective policy with an empty/default + # one). Falling through to DEFAULT_CONFIG here drops EVERY user + # override — including security-critical ``approvals.deny`` + # rules, which are supposed to block commands even under yolo. + # A long-running gateway whose user mid-edits config.yaml into + # broken YAML would silently lose those rules on the next load. + # Within a running process we still have the last successfully + # loaded config — keep serving it until the file is fixed. + # Fresh processes with no last-known-good keep the existing + # DEFAULT_CONFIG fallback. + lkg = _LAST_EXPANDED_CONFIG_BY_PATH.get(path_key) + _warn_config_parse_failure( + config_path, + e, + fallback="last-known-good" if lkg is not None else "defaults", + ) + if lkg is not None: + # save_config() stores the pre-expansion normalized dict + # (env-ref templates preserved); the load path stores the + # expanded one. Expand defensively — idempotent when the + # stored value is already expanded. + from typing import cast as _cast + lkg_copy: Dict[str, Any] = _cast( + Dict[str, Any], _expand_env_vars(copy.deepcopy(lkg)) + ) + if cache_sig is not None: + # Cache under the corrupt file's signature (empty env + # snapshot: always valid) so repeated loads don't + # re-parse the broken file; fixing the file changes the + # signature and triggers a normal reload. + _empty_env: Dict[str, Optional[str]] = {} + _LOAD_CONFIG_CACHE[path_key] = ( + cache_sig[0], cache_sig[1], + cache_sig[2], cache_sig[3], + lkg_copy, _empty_env, + ) + return copy.deepcopy(lkg_copy) if want_deepcopy else lkg_copy normalized = _normalize_root_model_keys(_normalize_max_turns_config(config)) expanded = _expand_env_vars(normalized) diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 06bc395214f7..dfc1c457cd39 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -266,6 +266,99 @@ class TestLoadConfigParseFailure: assert not list(tmp_path.glob("config.yaml.corrupt.*.bak")) + def test_last_known_good_retained_within_process(self, tmp_path, capsys): + """Port of openai/codex#31188's invariant: a parse failure must not + silently replace the effective config (policy included) with + defaults when the process already loaded a good config. + + Scenario: long-running gateway, user mid-edits config.yaml into + broken YAML. Before this fix the next load_config() dropped every + override — including ``approvals.deny`` security rules. Now the + last successfully loaded config keeps being served until the file + parses again. + """ + import time + from hermes_cli import config as cfg_mod + cfg_mod._CONFIG_PARSE_WARNED.clear() + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + cfg = tmp_path / "config.yaml" + cfg.write_text( + "model:\n default: test/custom-model\n" + "approvals:\n deny:\n - 'curl*evil.com*'\n" + ) + + good = load_config() + assert good["model"]["default"] == "test/custom-model" + assert good["approvals"]["deny"] == ["curl*evil.com*"] + capsys.readouterr() + + # Corrupt the file (mtime must change to bust the cache) + time.sleep(0.05) + cfg.write_text("approvals:\n deny: [unclosed\n :::bad {{{\n") + + after = load_config() + # Last-known-good retained — NOT defaults + assert after["model"]["default"] == "test/custom-model" + assert after["approvals"]["deny"] == ["curl*evil.com*"] + # Warning says we kept the previous config, not defaults + err = capsys.readouterr().err + assert "previously loaded config" in err + + def test_last_known_good_recovers_after_fix(self, tmp_path): + """Fixing the YAML picks up the new content on the next load.""" + import time + from hermes_cli import config as cfg_mod + cfg_mod._CONFIG_PARSE_WARNED.clear() + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + cfg = tmp_path / "config.yaml" + cfg.write_text("model:\n default: test/first\n") + assert load_config()["model"]["default"] == "test/first" + + time.sleep(0.05) + cfg.write_text("\tbroken:\n") + assert load_config()["model"]["default"] == "test/first" + + time.sleep(0.05) + cfg.write_text("model:\n default: test/second\n") + assert load_config()["model"]["default"] == "test/second" + + def test_fresh_process_still_falls_back_to_defaults(self, tmp_path): + """With no last-known-good (fresh process for this path), a broken + config still falls back to DEFAULT_CONFIG as before.""" + from hermes_cli import config as cfg_mod + cfg_mod._CONFIG_PARSE_WARNED.clear() + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + (tmp_path / "config.yaml").write_text("\tbroken:\n") + # No prior good load for this path in _LAST_EXPANDED_CONFIG_BY_PATH + cfg_mod._LAST_EXPANDED_CONFIG_BY_PATH.pop( + str(tmp_path / "config.yaml"), None + ) + config = load_config() + assert config["model"] == DEFAULT_CONFIG["model"] + + def test_last_known_good_cached_no_rewarn_spam(self, tmp_path, capsys): + """Repeated loads of the same broken file serve the cached LKG and + don't re-warn (dedup on mtime/size still applies).""" + import time + from hermes_cli import config as cfg_mod + cfg_mod._CONFIG_PARSE_WARNED.clear() + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + cfg = tmp_path / "config.yaml" + cfg.write_text("model:\n default: test/custom\n") + load_config() + time.sleep(0.05) + cfg.write_text("\tbroken:\n") + + load_config() + capsys.readouterr() + second = load_config() + assert second["model"]["default"] == "test/custom" + assert capsys.readouterr().err == "" + class TestSaveAndLoadRoundtrip: @staticmethod From a23d5073fbd2efa133f96fd60ad172799de062e7 Mon Sep 17 00:00:00 2001 From: joaomarcos <joaomarcosdias444@gmail.com> Date: Wed, 8 Jul 2026 10:53:13 -0300 Subject: [PATCH 328/610] fix(agent): stop switch_model from pairing new provider with stale base_url switch_model() unconditionally set agent.provider but only set agent.base_url when the resolved value was truthy. When a real provider change resolved an empty base_url (e.g. minimax after copilot), the agent ended up with provider="minimax" but base_url still pointing at api.githubcopilot.com. That incoherent pair then got snapshotted into agent._primary_runtime, so it kept re-applying on every subsequent turn via restore_primary_runtime() until the process restarted. try_activate_fallback() and _swap_credential() were audited and confirmed unaffected: both always derive base_url from an actually constructed client, never from a possibly-empty resolver hint. Fix: when base_url is empty AND the provider is genuinely changing, raise ValueError instead of silently keeping the old provider's URL. This routes through switch_model()'s existing snapshot/rollback path, and callers (tui_gateway/server.py's _apply_model_switch) already catch and surface a clean "switch failed, staying on X" message. Re-selecting the SAME provider with an empty base_url (credential-only refresh) still keeps the current URL, unchanged. Fixes #47828 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- agent/agent_runtime_helpers.py | 27 ++++-- .../test_switch_model_stale_base_url.py | 87 +++++++++++++++++++ 2 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 tests/run_agent/test_switch_model_stale_base_url.py diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 3e5cd63536bb..14c7c73321da 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1816,13 +1816,30 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo # ── Swap core runtime fields ── agent.model = new_model agent.provider = new_provider - # Use new base_url when provided; only fall back to current when the - # new provider genuinely has no endpoint (e.g. native SDK providers). - # Without this guard the old provider's URL (e.g. Ollama's localhost - # address) would persist silently after switching to a cloud provider - # that returns an empty base_url string. + # Use the new base_url when provided. When it's empty AND the + # provider is actually changing, do NOT fall back to the current + # (old provider's) URL — that silently pairs the new provider label + # with the previous provider's endpoint (e.g. new_provider=minimax + # paired with the leftover api.githubcopilot.com URL), and every + # request after the switch 400s at the wrong host. This mismatched + # pair also gets snapshotted into _primary_runtime below, so it + # keeps re-applying on every subsequent turn until a full restart. + # Fail loud instead: the caller (model_switch.switch_model()) + # already resolves base_url for every real provider, so an empty + # value here means resolution failed upstream, not that the + # provider genuinely has none. Re-selecting the SAME provider with + # an empty base_url (e.g. a credential-only refresh) is still fine + # to keep the current URL. See #47828. + old_norm_provider = (old_provider or "").strip().lower() + new_norm_provider = (new_provider or "").strip().lower() if base_url: agent.base_url = base_url + elif old_norm_provider != new_norm_provider: + raise ValueError( + f"switch_model: no base_url resolved for provider " + f"'{new_provider}' (switching from '{old_provider}'); " + "refusing to keep the previous provider's endpoint" + ) agent.api_mode = api_mode # Invalidate transport cache — new api_mode may need a different transport if hasattr(agent, "_transport_cache"): diff --git a/tests/run_agent/test_switch_model_stale_base_url.py b/tests/run_agent/test_switch_model_stale_base_url.py new file mode 100644 index 000000000000..bd38eb6c4981 --- /dev/null +++ b/tests/run_agent/test_switch_model_stale_base_url.py @@ -0,0 +1,87 @@ +"""Regression tests for #47828: switch_model must not pair a new provider +label with the previous provider's base_url when the resolver returns no +new base_url for a genuine provider change. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from run_agent import AIAgent +from agent.context_compressor import ContextCompressor + + +def _make_agent_with_compressor(provider="copilot", base_url="https://api.githubcopilot.com") -> AIAgent: + """Build a minimal AIAgent with a context_compressor, skipping __init__.""" + agent = AIAgent.__new__(AIAgent) + + agent.model = "claude-opus-4.8" + agent.provider = provider + agent.base_url = base_url + agent.api_key = "sk-primary" + agent.api_mode = "chat_completions" + agent.client = MagicMock() + agent.quiet_mode = True + agent._config_context_length = None + + compressor = ContextCompressor( + model=agent.model, + threshold_percent=0.50, + base_url=base_url, + api_key="sk-primary", + provider=provider, + quiet_mode=True, + config_context_length=None, + ) + agent.context_compressor = compressor + agent._primary_runtime = {} + + return agent + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_model_rejects_stale_base_url_on_provider_change(mock_ctx_len): + """A provider change with no resolved base_url must fail loud instead of + silently keeping the previous provider's endpoint (#47828).""" + agent = _make_agent_with_compressor(provider="copilot", base_url="https://api.githubcopilot.com") + + with pytest.raises(ValueError, match="no base_url resolved"): + agent.switch_model("MiniMax-M3", "custom:minimax", api_key="sk-minimax", base_url="") + + # Rollback must leave the agent fully on the old (provider, base_url) pair — + # not a mismatched new-model/old-endpoint hybrid. + assert agent.provider == "copilot" + assert agent.base_url == "https://api.githubcopilot.com" + assert agent.model == "claude-opus-4.8" + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_model_allows_empty_base_url_for_same_provider(mock_ctx_len): + """Re-selecting the SAME provider (e.g. a credential-only refresh) with no + new base_url must keep the current URL — this is not a provider change.""" + agent = _make_agent_with_compressor(provider="openrouter", base_url="https://openrouter.ai/api/v1") + + agent.switch_model("new-model", "openrouter", api_key="sk-new", base_url="") + + assert agent.provider == "openrouter" + assert agent.base_url == "https://openrouter.ai/api/v1" + assert agent.model == "new-model" + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_model_applies_new_base_url_on_provider_change(mock_ctx_len): + """The normal, resolved-correctly path must still work: new provider + + new base_url is applied as-is.""" + agent = _make_agent_with_compressor(provider="copilot", base_url="https://api.githubcopilot.com") + + agent.switch_model( + "MiniMax-M3", "custom:minimax", api_key="sk-minimax", base_url="https://api.minimax.io/v1" + ) + + assert agent.provider == "custom:minimax" + assert agent.base_url == "https://api.minimax.io/v1" + assert agent.model == "MiniMax-M3" + # _primary_runtime must snapshot the coherent pair so it survives every + # subsequent restore_primary_runtime() call across turns. + assert agent._primary_runtime["provider"] == "custom:minimax" + assert agent._primary_runtime["base_url"] == "https://api.minimax.io/v1" From 1f57ed2a53f0ab4eec515c8a6abf44dc1e52fdaa Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:28:12 +0300 Subject: [PATCH 329/610] fix(export): escape tool-call name in HTML session export The HTML session export interpolated the tool-call name into the page without escaping, while every sibling field went through _escape_html. A tool-call name is attacker-influenced, so a prompt-injected model can emit a name containing HTML that executes when the export is opened in a browser. Escape the tool-call name like the other fields. --- hermes_cli/session_export_html.py | 2 +- tests/hermes_cli/test_session_export_html.py | 60 ++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_session_export_html.py diff --git a/hermes_cli/session_export_html.py b/hermes_cli/session_export_html.py index 6b3821ed03e8..2489cf653a76 100644 --- a/hermes_cli/session_export_html.py +++ b/hermes_cli/session_export_html.py @@ -706,7 +706,7 @@ def _generate_messages_html(messages: List[Dict[str, Any]]) -> str: <div class="tool-call"> <div class="tool-call-header"> {ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ')} - {ICON_WRENCH} Tool Call: {fn_name} + {ICON_WRENCH} Tool Call: {_escape_html(fn_name)} </div> <div class="tool-call-content"> <pre><code>{_escape_html(args)}</code></pre> diff --git a/tests/hermes_cli/test_session_export_html.py b/tests/hermes_cli/test_session_export_html.py new file mode 100644 index 000000000000..cadfb6e19a1d --- /dev/null +++ b/tests/hermes_cli/test_session_export_html.py @@ -0,0 +1,60 @@ +"""Tests for the HTML session export renderer.""" + +from hermes_cli.session_export_html import ( + _generate_messages_html, + generate_multi_session_html_export, +) + + +def test_tool_call_name_is_escaped(): + """A tool-call name is attacker-influenced (a prompt-injected model can emit + an arbitrary name), so it must be HTML-escaped like every sibling field.""" + payload = '<img src=x onerror="alert(1)">' + html = _generate_messages_html([ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": payload, "arguments": "{}"}, + } + ], + } + ]) + assert payload not in html + assert "<img src=x onerror=" in html + + +def test_tool_call_arguments_stay_escaped(): + html = _generate_messages_html([ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "terminal", "arguments": "<b>x</b>"}, + } + ], + } + ]) + assert "<b>x</b>" in html + assert "<b>x</b>" not in html + + +def test_multi_session_export_keeps_switcher_script(): + """The multi-session export drives session switching with an inline script, + so the escaping fix must not remove or block that script.""" + sessions = [ + {"id": "aaaa1111", "title": "First", "started_at": 0, + "messages": [{"role": "user", "content": "one"}]}, + {"id": "bbbb2222", "title": "Second", "started_at": 0, + "messages": [{"role": "user", "content": "two"}]}, + ] + html = generate_multi_session_html_export(sessions) + assert "function showSession" in html + assert 'data-id="aaaa1111"' in html + assert 'id="view-bbbb2222"' in html From 501616e8e64191eeced054cd031b041e94e302d4 Mon Sep 17 00:00:00 2001 From: chenkun <chenkun_lws@126.com> Date: Wed, 8 Jul 2026 00:14:49 +0800 Subject: [PATCH 330/610] fix(cli): set HERMES_YOLO_MODE before plugin discovery at startup --- hermes_cli/main.py | 15 +++- tests/hermes_cli/test_yolo_startup_order.py | 77 +++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_yolo_startup_order.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ddaa3ee31989..f4356f2c62a7 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2358,7 +2358,11 @@ def cmd_chat(args): except Exception: pass - # --yolo: bypass all dangerous command approvals + # --yolo: bypass all dangerous command approvals. + # Also set in main() before _prepare_agent_startup() — that is the + # authoritative site because it runs before tool imports freeze + # _YOLO_MODE_FROZEN. This redundant set is a safety net for callers + # that invoke cmd_chat directly (e.g. subcommand dispatch). if getattr(args, "yolo", False): os.environ["HERMES_YOLO_MODE"] = "1" @@ -14627,6 +14631,15 @@ def main(): cmd_version(args) return + # --yolo: set HERMES_YOLO_MODE *before* plugin discovery. The call to + # _prepare_agent_startup() below triggers discover_plugins() → tool + # imports, and tools.approval freezes _YOLO_MODE_FROZEN at module + # import time (PR #7994, security hardening against prompt-injection). + # If the env var is set only later (e.g. inside cmd_chat), the frozen + # value is already False and --yolo silently does nothing. + if getattr(args, "yolo", False): + os.environ["HERMES_YOLO_MODE"] = "1" + # Discover Python plugins and register shell hooks once, before any # command that can fire lifecycle hooks. Both are idempotent; gated # so introspection/management commands (hermes hooks list, cron diff --git a/tests/hermes_cli/test_yolo_startup_order.py b/tests/hermes_cli/test_yolo_startup_order.py new file mode 100644 index 000000000000..6d30b776fbd9 --- /dev/null +++ b/tests/hermes_cli/test_yolo_startup_order.py @@ -0,0 +1,77 @@ +"""Regression tests for #60328: --yolo must set HERMES_YOLO_MODE in +main() before _prepare_agent_startup() triggers tool imports. + +The freeze mechanism in tools.approval (_YOLO_MODE_FROZEN) is correct +by design (PR #7994). The bug was that main() set the env var inside +cmd_chat(), which runs *after* _prepare_agent_startup() has already +imported tools.approval and frozen the constant to False. + +These tests verify the ordering in main() itself: the env var must +already be set at the moment _prepare_agent_startup() is called. +If someone moves the assignment back into cmd_chat(), these tests +fail — catching the exact #60328 regression. +""" + +import os +import sys + + +def _run_main_and_capture_yolo_at_startup(monkeypatch, argv): + """Run main() with *argv*, capturing HERMES_YOLO_MODE at the + moment _prepare_agent_startup is called. + + Returns the captured env var value (or None if unset). + """ + yolo_at_startup = {} + + def spy_prepare_startup(args): + yolo_at_startup["value"] = os.environ.get("HERMES_YOLO_MODE") + + monkeypatch.setattr( + "hermes_cli.main._prepare_agent_startup", spy_prepare_startup + ) + # Stub cmd_chat so main() returns cleanly without entering chat. + monkeypatch.setattr("hermes_cli.main.cmd_chat", lambda args: None) + monkeypatch.delenv("HERMES_YOLO_MODE", raising=False) + monkeypatch.setattr(sys, "argv", argv) + + from hermes_cli.main import main as cli_main + + cli_main() + + return yolo_at_startup.get("value") + + +def test_top_level_yolo_flag_sets_env_before_startup(monkeypatch): + """hermes --yolo must set HERMES_YOLO_MODE before + _prepare_agent_startup imports tools.approval.""" + result = _run_main_and_capture_yolo_at_startup( + monkeypatch, ["hermes", "--yolo"] + ) + assert result == "1", ( + "HERMES_YOLO_MODE was not '1' when _prepare_agent_startup was " + "called from main() with --yolo. This is the #60328 regression: " + "the env var is set too late (inside cmd_chat, after tool imports)." + ) + + +def test_chat_subcommand_yolo_flag_sets_env_before_startup(monkeypatch): + """hermes chat --yolo must also set HERMES_YOLO_MODE before + _prepare_agent_startup.""" + result = _run_main_and_capture_yolo_at_startup( + monkeypatch, ["hermes", "chat", "--yolo"] + ) + assert result == "1", ( + "HERMES_YOLO_MODE was not '1' when _prepare_agent_startup was " + "called from main() with 'chat --yolo'." + ) + + +def test_no_yolo_flag_leaves_env_unset_at_startup(monkeypatch): + """Without --yolo, HERMES_YOLO_MODE must not be set at startup.""" + result = _run_main_and_capture_yolo_at_startup( + monkeypatch, ["hermes"] + ) + assert result is None, ( + "HERMES_YOLO_MODE was unexpectedly set at startup without --yolo." + ) From d2e64fcb89cd180c6657bbe60723980fc5498778 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:45:15 -0700 Subject: [PATCH 331/610] fix(cli): widen --yolo env guarantee to the _prepare_agent_startup chokepoint + AUTHOR_MAP The salvaged fix sets HERMES_YOLO_MODE in main()'s dispatch path before _prepare_agent_startup(); this follow-up also sets it inside _prepare_agent_startup() itself so every launcher that triggers plugin/tool discovery (incl. the Termux fast-CLI path) gets the same ordering guarantee before tools.approval freezes _YOLO_MODE_FROZEN (#60328). --- hermes_cli/main.py | 9 +++++++++ scripts/release.py | 1 + 2 files changed, 10 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f4356f2c62a7..9e5267fe428c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12414,6 +12414,15 @@ def _should_background_mcp_startup(args) -> bool: def _prepare_agent_startup(args) -> None: """Discover plugins/MCP/hooks for commands that can run an agent turn.""" + # --yolo: chokepoint guarantee that HERMES_YOLO_MODE is set before ANY + # plugin/tool discovery below imports tools.approval, which freezes + # _YOLO_MODE_FROZEN at import time (PR #7994 security design). main()'s + # dispatch path also sets this earlier, but _prepare_agent_startup() is + # reachable from other launchers too (e.g. the Termux fast-CLI path), + # so the guarantee lives here where the import is actually triggered + # (#60328). + if getattr(args, "yolo", False): + os.environ["HERMES_YOLO_MODE"] = "1" _apply_safe_mode(args) _sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None)) diff --git a/scripts/release.py b/scripts/release.py index 788cb157bfd8..4957e53a6708 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -299,6 +299,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "chenkun_lws@126.com": "bytesnail", # PR #60360 salvage (--yolo startup ordering; #60328) "iamgexin@qq.com": "nullptr0807", # PR #60956 salvage (gateway hygiene in-place compaction; #60947) "embwl0x@users.noreply.github.com": "embwl0x", # PR #60810 salvage (channel directory offload) "caztronics@yahoo.com": "doncazper", From c71d19c0ead61b7f93535c4d87b8da0f35d111f5 Mon Sep 17 00:00:00 2001 From: bassis ho <bassisho@Mac-mini-bassis.local> Date: Thu, 9 Jul 2026 15:53:03 +0700 Subject: [PATCH 332/610] fix(cron): id-less job no longer freezes the whole scheduler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cron record authored by a direct jobs.json edit that bypassed add_job() can lack an "id" key (older writers used "job_id"). Every site in _get_due_jobs_locked indexes job["id"] eagerly — both the logging helpers (job.get("name", job["id"]) evaluates the default argument unconditionally) and the 'for rj in raw_jobs: if rj["id"] == job["id"]' persistence loops. A single malformed record therefore raised KeyError mid-tick, aborting the entire scan before save_jobs() ran. Result: healthy jobs' fast-forwarded next_run_at was computed in memory then discarded on the exception unwind, freezing the whole profile's scheduler in a per-minute loop (observed dormant for weeks). Fix: normalize id-less records at the top of _get_due_jobs_locked before anything keys off job["id"] — recover the id from a drifted "job_id" key when present, else synthesize one via uuid4, and persist. This repairs the whole bug class at the source rather than guarding each of the ~12 downstream index sites. Adds a regression test that fails with KeyError on the current code and passes with the fix, asserting a healthy sibling job is still returned when an id-less record shares the store. --- cron/jobs.py | 32 ++++++++++++++++++++++++-------- tests/cron/test_jobs.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/cron/jobs.py b/cron/jobs.py index 76cfc60cbf5d..27e055cedfb6 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -1452,7 +1452,7 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None, "Job '%s' (%s) could not compute next_run_at; " "leaving enabled and marking state=error so the " "job is not silently disabled.", - job.get("name", job["id"]), + job.get("name", job.get("id", "?")), kind, ) else: @@ -1506,7 +1506,7 @@ def claim_dispatch(job_id: str) -> bool: save_jobs(jobs) logger.info( "Job '%s': dispatch limit reached (%d/%d) — removing", - job.get("name", job["id"]), + job.get("name", job.get("id", "?")), completed, times, ) @@ -1516,7 +1516,7 @@ def claim_dispatch(job_id: str) -> bool: save_jobs(jobs) logger.debug( "Job '%s': claimed dispatch %d/%d", - job.get("name", job["id"]), + job.get("name", job.get("id", "?")), repeat["completed"], times, ) @@ -1653,9 +1653,25 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]: """Inner implementation of get_due_jobs(); must be called with _jobs_lock held.""" now = _hermes_now() raw_jobs = load_jobs() + needs_save = False + + # Repair id-less records BEFORE anything keys off ``job["id"]``. A direct + # jobs.json edit that bypassed add_job() can leave a record without an "id" + # (older writers used "job_id"). Every downstream site — the logging + # helpers and the ``for rj in raw_jobs: if rj["id"] == job["id"]`` + # persistence loops — indexes job["id"] eagerly, so a single malformed + # record raised KeyError mid-tick, aborting the whole scan before + # save_jobs() ran. That froze the entire profile's scheduler in a + # per-minute fast-forward loop (healthy jobs recomputed in memory, then + # discarded when the exception unwound). Recover the id from the drifted + # "job_id" key when present, else synthesize one, and persist. + for rj in raw_jobs: + if not rj.get("id"): + rj["id"] = rj.pop("job_id", None) or uuid.uuid4().hex[:12] + needs_save = True + jobs = [_apply_skill_fields(j) for j in copy.deepcopy(raw_jobs)] due = [] - needs_save = False # Resolve the one-shot running-claim stale-recovery TTL once per scan # (derived from HERMES_CRON_TIMEOUT). See _oneshot_run_claim_ttl_seconds. _run_claim_ttl = _oneshot_run_claim_ttl_seconds() @@ -1716,7 +1732,7 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]: next_run = recovered_next logger.info( "Job '%s' had no next_run_at; recovering %s run at %s", - job.get("name", job["id"]), + job.get("name", job.get("id", "?")), recovery_kind, recovered_next, ) @@ -1757,7 +1773,7 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]: logger.info( "Job '%s' next_run_at offset changed (%s -> %s). " "Recomputing cron run to preserve local wall-clock intent: %s", - job.get("name", job["id"]), + job.get("name", job.get("id", "?")), raw_next_run_dt.utcoffset(), now.utcoffset(), new_next, @@ -1785,7 +1801,7 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]: "Job '%s' missed its scheduled time (%s, grace=%ds). " "Running now; next run provisionally set to: %s " "(re-anchored on completion)", - job.get("name", job["id"]), + job.get("name", job.get("id", "?")), next_run, grace, new_next, @@ -1819,7 +1835,7 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]: logger.info( "Job '%s': one-shot dispatch limit reached (%d/%d) " "— removing stale due entry", - job.get("name", job["id"]), + job.get("name", job.get("id", "?")), completed, times, ) diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index d0adf7ed1bd1..6cc4a1b46866 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -835,6 +835,40 @@ class TestGetDueJobs: next_dt = _ensure_aware(datetime.fromisoformat(updated["next_run_at"])) assert next_dt > _hermes_now() + def test_idless_job_does_not_crash_or_block_sibling_jobs(self, tmp_cron_dir): + """A job missing its 'id' key must not crash the tick or freeze siblings. + + Regression: jobs authored by a direct jobs.json edit (bypassing + create_job) sometimes used the key 'job_id' instead of 'id'. The logging + helpers evaluated ``job.get("name", job["id"])`` -- Python evaluates the + default argument ``job["id"]`` eagerly, so an id-less job raised + ``KeyError: 'id'`` mid-tick. That exception aborted + ``_get_due_jobs_locked()`` BEFORE ``save_jobs()`` ran, so every healthy + job's fast-forwarded next_run_at was computed in memory then discarded -- + the whole profile's scheduler froze in a per-minute loop. + """ + healthy = create_job(prompt="Healthy", schedule="every 1h") + + jobs = load_jobs() + # Push the healthy job beyond its grace window so the fast-forward path + # (one of the id-less-crash sites) runs. + jobs[0]["next_run_at"] = (datetime.now() - timedelta(minutes=35)).isoformat() + # A malformed record: no 'id' key, mirroring the real corruption. + jobs.append({ + "name": "idless-job", + "schedule": {"kind": "cron", "expr": "0 4 * * *"}, + "enabled": True, + "no_agent": True, + "next_run_at": None, + }) + save_jobs(jobs) + + # Must not raise KeyError. + due = get_due_jobs() + + # The healthy sibling is still discovered despite the malformed neighbor. + assert any(d.get("id") == healthy["id"] for d in due) + def test_long_execution_does_not_perpetually_defer(self, tmp_cron_dir, monkeypatch): """#33315: a recurring job whose runtime exceeds interval+grace must still From 8e2ce43525cfd1371f68443ef7e68f6bf6871d6a Mon Sep 17 00:00:00 2001 From: dsad <sswdarius@gmail.com> Date: Thu, 9 Jul 2026 18:10:10 +0300 Subject: [PATCH 333/610] fix(cron): non-dict schedule no longer freezes the whole scheduler A job record in jobs.json can have a non-dict 'schedule' value (null, string, etc.) from direct edit or old writers. In _get_due_jobs_locked: schedule = job.get('schedule', {}) kind = schedule.get('kind') This (and direct schedule['kind'] in compute_next_run etc.) raises and aborts the entire due-jobs scan before save_jobs() or advancing next_run_at for healthy jobs. Exactly the same failure mode as the id-less job P1. Fix: normalize non-dict schedules to {} early (before any use), matching the defense added for id-less records. Also added defensive guards in compute functions. Added regression test that a bad schedule does not crash and healthy sibling is still returned. Refs similar pattern in #61382. --- cron/jobs.py | 64 ++++++++++++++++++++++++++++++----------- tests/cron/test_jobs.py | 37 ++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 17 deletions(-) diff --git a/cron/jobs.py b/cron/jobs.py index 27e055cedfb6..3865e02b3efa 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -559,7 +559,7 @@ def _recoverable_oneshot_run_at( their requested minute still run on the next tick. Once a one-shot has already run, it is never eligible again. """ - if schedule.get("kind") != "once": + if not isinstance(schedule, dict) or schedule.get("kind") != "once": return None if last_run_at: return None @@ -592,16 +592,18 @@ def _compute_grace_seconds(schedule: dict) -> int: return max(MIN_GRACE, min(grace, MAX_GRACE)) if kind == "cron" and HAS_CRONITER: - try: - now = _hermes_now() - cron = croniter(schedule["expr"], now) - first = cron.get_next(datetime) - second = cron.get_next(datetime) - period_seconds = int((second - first).total_seconds()) - grace = period_seconds // 2 - return max(MIN_GRACE, min(grace, MAX_GRACE)) - except Exception: - pass + expr = schedule.get("expr") + if expr: + try: + now = _hermes_now() + cron = croniter(expr, now) + first = cron.get_next(datetime) + second = cron.get_next(datetime) + period_seconds = int((second - first).total_seconds()) + grace = period_seconds // 2 + return max(MIN_GRACE, min(grace, MAX_GRACE)) + except Exception: + pass return MIN_GRACE @@ -614,11 +616,19 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None """ now = _hermes_now() - if schedule["kind"] == "once": + if not isinstance(schedule, dict): + return None + kind = schedule.get("kind") + if kind is None: + return None + + if kind == "once": return _recoverable_oneshot_run_at(schedule, now, last_run_at=last_run_at) - elif schedule["kind"] == "interval": - minutes = schedule["minutes"] + elif kind == "interval": + minutes = schedule.get("minutes") + if minutes is None: + return None if last_run_at: # Next run is last_run + interval last = _ensure_aware(datetime.fromisoformat(last_run_at)) @@ -628,14 +638,17 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None next_run = now + timedelta(minutes=minutes) return next_run.isoformat() - elif schedule["kind"] == "cron": + elif kind == "cron": + expr = schedule.get("expr") + if not expr: + return None if not HAS_CRONITER: logger.warning( "Cannot compute next run for cron schedule %r: 'croniter' is " "not installed. croniter is a core dependency as of v0.9.x; " "reinstall hermes-agent or run 'pip install croniter' in your " "runtime env.", - schedule.get("expr"), + expr, ) return None # Use last_run_at as the croniter base when available, consistent @@ -645,7 +658,7 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None base_time = now if last_run_at: base_time = _ensure_aware(datetime.fromisoformat(last_run_at)) - cron = croniter(schedule["expr"], base_time) + cron = croniter(expr, base_time) next_run = cron.get_next(datetime) return next_run.isoformat() @@ -1672,6 +1685,23 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]: jobs = [_apply_skill_fields(j) for j in copy.deepcopy(raw_jobs)] due = [] + + # Normalize malformed "schedule" records (direct jobs.json edit, old writers, + # corruption, etc.). "schedule" must be a dict; a null/string/etc. value + # makes `schedule.get("kind")` or direct `schedule["kind"]` / ["expr"] / + # ["minutes"] later raise and abort the entire scan *before* save_jobs(). + # Healthy jobs then lose their fast-forwarded next_run_at (exactly the + # failure mode of the id-less job bug fixed above). Repair early at the + # source so the rest of the tick can proceed and persist progress for + # siblings. + for j in jobs: + if not isinstance(j.get("schedule"), dict): + j["schedule"] = {} + needs_save = True + for rj in raw_jobs: + if not isinstance(rj.get("schedule"), dict): + rj["schedule"] = {} + needs_save = True # Resolve the one-shot running-claim stale-recovery TTL once per scan # (derived from HERMES_CRON_TIMEOUT). See _oneshot_run_claim_ttl_seconds. _run_claim_ttl = _oneshot_run_claim_ttl_seconds() diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index 6cc4a1b46866..ada767e7e0dc 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -1636,3 +1636,40 @@ class TestClaimDispatch: due = get_due_jobs() assert due == [] assert load_jobs() == [] # cleaned up + + def test_bad_schedule_does_not_crash_or_block_sibling_jobs(self, tmp_cron_dir): + """Regression for a job with non-dict 'schedule' (null / string / etc. + + from direct jobs.json edit or old writer). + + Such a record must not raise in _get_due_jobs_locked and must not + prevent healthy sibling jobs from being returned or having their + next_run_at advanced+persisted. Mirrors the id-less job P1 pattern. + """ + past = (datetime.now(timezone.utc) - timedelta(seconds=10)).isoformat() + future = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat() + + bad = { + "id": "bad-sched", + "name": "bad", + "enabled": True, + "schedule": None, # poison: not a dict + "next_run_at": future, # not due + } + good = { + "id": "good", + "name": "good", + "enabled": True, + "schedule": {"kind": "interval", "minutes": 5}, + "next_run_at": past, + } + save_jobs([bad, good]) + + due = get_due_jobs() + due_ids = [j["id"] for j in due] + assert "good" in due_ids + assert "bad-sched" not in due_ids # bad one ignored, no crash + + # At minimum, the good job's record is still intact (no corruption from the bad neighbor) + loaded = {j["id"]: j for j in load_jobs()} + assert "good" in loaded From 26f040ef202d4b4d054def52473e44c9c44478d9 Mon Sep 17 00:00:00 2001 From: dsad <sswdarius@gmail.com> Date: Thu, 9 Jul 2026 20:00:38 +0300 Subject: [PATCH 334/610] fix(cron): malformed next_run_at no longer freezes the scheduler One bad next_run_at value in jobs.json aborts the due-jobs scan with ValueError from fromisoformat, before any save_jobs, so siblings lose progress (fast-forwards etc). Early normalization in _get_due_jobs_locked + defensive parses in compute_next_run / _recoverable_oneshot_run_at. Added test_bad_next_run_at_does_not_crash_or_block_sibling_jobs. --- cron/jobs.py | 75 ++++++++++++++++++++++++++++++++++++++--- tests/cron/test_jobs.py | 57 +++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/cron/jobs.py b/cron/jobs.py index 3865e02b3efa..cc9d1351d75a 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -568,7 +568,10 @@ def _recoverable_oneshot_run_at( if not run_at: return None - run_at_dt = _ensure_aware(datetime.fromisoformat(run_at)) + try: + run_at_dt = _ensure_aware(datetime.fromisoformat(run_at)) + except Exception: + return None if run_at_dt >= now - timedelta(seconds=ONESHOT_GRACE_SECONDS): return run_at return None @@ -630,9 +633,11 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None if minutes is None: return None if last_run_at: - # Next run is last_run + interval - last = _ensure_aware(datetime.fromisoformat(last_run_at)) - next_run = last + timedelta(minutes=minutes) + try: + last = _ensure_aware(datetime.fromisoformat(last_run_at)) + next_run = last + timedelta(minutes=minutes) + except Exception: + next_run = now + timedelta(minutes=minutes) else: # First run is now + interval next_run = now + timedelta(minutes=minutes) @@ -657,7 +662,10 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None # rather than to an arbitrary restart time. base_time = now if last_run_at: - base_time = _ensure_aware(datetime.fromisoformat(last_run_at)) + try: + base_time = _ensure_aware(datetime.fromisoformat(last_run_at)) + except Exception: + base_time = now cron = croniter(expr, base_time) next_run = cron.get_next(datetime) return next_run.isoformat() @@ -1702,6 +1710,63 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]: if not isinstance(rj.get("schedule"), dict): rj["schedule"] = {} needs_save = True + + # Normalize malformed "next_run_at" records (direct jobs.json edit, + # corruption, migration, or buggy writer). If present but not a valid + # ISO string, datetime.fromisoformat(next_run) later raises and aborts + # the entire scan *before* save_jobs(). Healthy siblings then lose any + # fast-forwarded next_run_at (same class of bug as bad "id" or "schedule"). + # Strip the bad value so the existing "no next_run_at" recovery path + # recomputes a sane value and persists it for this job. + for j in jobs: + nr = j.get("next_run_at") + if nr is not None: + if not isinstance(nr, str): + j.pop("next_run_at", None) + needs_save = True + else: + try: + datetime.fromisoformat(nr) + except Exception: + j.pop("next_run_at", None) + needs_save = True + for rj in raw_jobs: + nr = rj.get("next_run_at") + if nr is not None: + if not isinstance(nr, str): + rj.pop("next_run_at", None) + needs_save = True + else: + try: + datetime.fromisoformat(nr) + except Exception: + rj.pop("next_run_at", None) + needs_save = True + + # Same treatment for last_run_at (used as base in recovery / compute_next_run). + for j in jobs: + lr = j.get("last_run_at") + if lr is not None and not isinstance(lr, str): + j.pop("last_run_at", None) + needs_save = True + elif isinstance(lr, str): + try: + datetime.fromisoformat(lr) + except Exception: + j.pop("last_run_at", None) + needs_save = True + for rj in raw_jobs: + lr = rj.get("last_run_at") + if lr is not None and not isinstance(lr, str): + rj.pop("last_run_at", None) + needs_save = True + elif isinstance(lr, str): + try: + datetime.fromisoformat(lr) + except Exception: + rj.pop("last_run_at", None) + needs_save = True + # Resolve the one-shot running-claim stale-recovery TTL once per scan # (derived from HERMES_CRON_TIMEOUT). See _oneshot_run_claim_ttl_seconds. _run_claim_ttl = _oneshot_run_claim_ttl_seconds() diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index ada767e7e0dc..0c9eef4c0a94 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -1446,6 +1446,63 @@ class TestMarkJobRunConcurrency: ) +class TestBadNextRunAtRecovery: + """Regression: malformed next_run_at must not crash the due scan or starve siblings. + + Mirrors the id-less and non-dict-schedule patterns: a single bad persisted + record in jobs.json must not abort _get_due_jobs_locked before save. + """ + + def test_bad_next_run_at_does_not_crash_or_block_sibling_jobs(self, tmp_cron_dir): + """One job with unparseable next_run_at + one healthy due sibling. + + get_due_jobs must succeed and return the healthy job; the bad record + must be repaired (next_run_at cleared so recovery can set a sane value). + """ + from datetime import timezone, timedelta as td + now = datetime.now(timezone.utc) + past = (now - td(seconds=30)).isoformat() + future = (now + td(days=1)).isoformat() + + # Bad record: next_run_at is not a valid ISO string (e.g. from hand-edit or corruption) + # Healthy sibling is past due with good schedule. + bad_job = { + "id": "bad-next", + "schedule": {"kind": "interval", "minutes": 60}, + "next_run_at": "not-a-valid-iso-timestamp!!!", + "enabled": True, + "created_at": past, + } + good_job = { + "id": "good-sibling", + "schedule": {"kind": "interval", "minutes": 5}, + "next_run_at": past, + "enabled": True, + "created_at": past, + } + save_jobs([bad_job, good_job]) + + # Must not raise + due = get_due_jobs() + + # The healthy job must still be returned + ids = [j["id"] for j in due] + assert "good-sibling" in ids, f"healthy sibling missing from due jobs: {ids}" + assert "bad-next" not in ids # bad one may be repaired and/or not yet due after repair + + # Bad job should have been auto-repaired (next_run_at stripped or fixed) + repaired = get_job("bad-next") + assert repaired is not None + nr = repaired.get("next_run_at") + if nr is not None: + # If still present it must now be parseable + datetime.fromisoformat(nr) + + # Calling again must remain stable (no crash on re-scan) + due2 = get_due_jobs() + assert any(j["id"] == "good-sibling" for j in due2) + + class TestSaveJobOutput: def test_creates_output_file(self, tmp_cron_dir): output_file = save_job_output("test123", "# Results\nEverything ok.") From 10c0d9b2a715100aee640cb4162fbdf73f896bd0 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:08:33 -0700 Subject: [PATCH 335/610] fix(cron): contain any per-job exception in the due scan; harden as a class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural completion of the malformed-job freeze fixes (#61382 id-less, #61525 non-dict schedule, #61581 bad next_run_at): wrap the per-job body of _get_due_jobs_locked in try/except so any FUTURE malformed-field variant degrades to skipping that one job for the tick instead of aborting the scan before save_jobs() and freezing the whole profile's scheduler. Also: restore test_repeated_concurrent_runs_accumulate_completed_count to TestMarkJobRunConcurrency (accidentally re-parented by the #61581 diff), add a containment regression test, and AUTHOR_MAP for hydracoco7. E2E: one jobs.json carrying all five malformed shapes (drifted job_id, missing id, null schedule, garbage next_run_at, non-string last_run_at) plus a healthy sibling — single tick contains all five, sibling fires, repairs persist, second tick stable. 670 cron tests green. --- cron/jobs.py | 335 +++++++++++++++++++++------------------- scripts/release.py | 1 + tests/cron/test_jobs.py | 54 +++++++ 3 files changed, 229 insertions(+), 161 deletions(-) diff --git a/cron/jobs.py b/cron/jobs.py index cc9d1351d75a..ff536ea75813 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -1772,201 +1772,214 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]: _run_claim_ttl = _oneshot_run_claim_ttl_seconds() for job in jobs: - if not job.get("enabled", True): - continue - - # Cross-process running-claim guard (#59229): if another scheduler - # process already claimed this one-shot and its run is still in flight - # (claim younger than the TTL), skip it — do NOT re-dispatch. The - # claim is stamped just before we return the job as due (below) and - # cleared by mark_job_run() on completion. A claim older than the TTL - # is treated as stale (the claiming tick died mid-run) and allowed - # through so the job is recovered rather than wedged forever. - existing_claim = job.get("run_claim") - if existing_claim and job.get("schedule", {}).get("kind") == "once": - try: - claimed_at = _ensure_aware( - datetime.fromisoformat(existing_claim["at"]) - ) - # 0 <= age: a future-dated claim (clock/TZ skew across a - # restart) must be treated as stale, not eternally fresh, - # or the one-shot is skipped forever (#60703). - _age = (now - claimed_at).total_seconds() - if 0 <= _age < _run_claim_ttl: - continue # a fresh claim is held by an in-flight run - except (KeyError, ValueError, TypeError): - pass # malformed claim → fall through and (re)claim - - next_run = job.get("next_run_at") - if not next_run: - schedule = job.get("schedule", {}) - kind = schedule.get("kind") - - # One-shot jobs use a small grace window via the dedicated helper. - recovered_next = _recoverable_oneshot_run_at( - schedule, - now, - last_run_at=job.get("last_run_at"), - ) - recovery_kind = "one-shot" if recovered_next else None - - # Recurring jobs reach here only when something — typically a - # direct jobs.json edit that bypassed add_job() — left - # next_run_at unset. Without this branch, such jobs are - # silently skipped forever; recompute next_run_at from the - # schedule so they pick up at their next scheduled tick. - if not recovered_next and kind in {"cron", "interval"}: - recovered_next = compute_next_run(schedule, now.isoformat()) - if recovered_next: - recovery_kind = kind - - if not recovered_next: + # Per-job containment (structural guard): one malformed or + # unexpected job record must never abort the whole scan. The id / + # schedule / timestamp normalizations above repair the known shapes; + # this guard catches every FUTURE variant, degrading to "skip this + # job this tick" so healthy siblings still run and their recovered + # state still reaches save_jobs() below. + try: + if not job.get("enabled", True): continue - job["next_run_at"] = recovered_next - next_run = recovered_next - logger.info( - "Job '%s' had no next_run_at; recovering %s run at %s", - job.get("name", job.get("id", "?")), - recovery_kind, - recovered_next, - ) - for rj in raw_jobs: - if rj["id"] == job["id"]: - rj["next_run_at"] = recovered_next - needs_save = True - break + # Cross-process running-claim guard (#59229): if another scheduler + # process already claimed this one-shot and its run is still in flight + # (claim younger than the TTL), skip it — do NOT re-dispatch. The + # claim is stamped just before we return the job as due (below) and + # cleared by mark_job_run() on completion. A claim older than the TTL + # is treated as stale (the claiming tick died mid-run) and allowed + # through so the job is recovered rather than wedged forever. + existing_claim = job.get("run_claim") + if existing_claim and job.get("schedule", {}).get("kind") == "once": + try: + claimed_at = _ensure_aware( + datetime.fromisoformat(existing_claim["at"]) + ) + # 0 <= age: a future-dated claim (clock/TZ skew across a + # restart) must be treated as stale, not eternally fresh, + # or the one-shot is skipped forever (#60703). + _age = (now - claimed_at).total_seconds() + if 0 <= _age < _run_claim_ttl: + continue # a fresh claim is held by an in-flight run + except (KeyError, ValueError, TypeError): + pass # malformed claim → fall through and (re)claim - raw_next_run_dt = datetime.fromisoformat(next_run) - schedule = job.get("schedule", {}) - kind = schedule.get("kind") + next_run = job.get("next_run_at") + if not next_run: + schedule = job.get("schedule", {}) + kind = schedule.get("kind") - next_run_dt = _ensure_aware(raw_next_run_dt) - # Migration repair: a cron job persists next_run_at as an absolute - # instant, but the cron expr describes local wall-clock intent. If the - # configured/system timezone changed after persistence, the stored - # instant's offset no longer matches now's, and its converted time can - # look due hours early (21:00+10 -> 13:00+02). When the stored *wall - # clock* is still in the future, recompute from the schedule so we fire - # at the intended local time instead of early-then-again. - # - # TRADE-OFF: this cannot distinguish a config/host TZ migration from a - # legitimate DST offset change. A DST boundary that satisfies all four - # conditions will recompute (and thus SKIP the pending occurrence, no - # catch-up) rather than fire it. Accepted: in the pure-migration case - # the recompute lands on the same wall-clock time later the same period, - # and DST-boundary collisions with a still-future stored wall clock are - # rare relative to the double-fire bug this prevents (#28934). - if ( - kind == "cron" - and next_run_dt <= now - and _timezone_offset_mismatch(raw_next_run_dt, now) - and _stored_wall_clock_is_future(raw_next_run_dt, now) - ): - new_next = compute_next_run(schedule, now.isoformat()) - if new_next: + # One-shot jobs use a small grace window via the dedicated helper. + recovered_next = _recoverable_oneshot_run_at( + schedule, + now, + last_run_at=job.get("last_run_at"), + ) + recovery_kind = "one-shot" if recovered_next else None + + # Recurring jobs reach here only when something — typically a + # direct jobs.json edit that bypassed add_job() — left + # next_run_at unset. Without this branch, such jobs are + # silently skipped forever; recompute next_run_at from the + # schedule so they pick up at their next scheduled tick. + if not recovered_next and kind in {"cron", "interval"}: + recovered_next = compute_next_run(schedule, now.isoformat()) + if recovered_next: + recovery_kind = kind + + if not recovered_next: + continue + + job["next_run_at"] = recovered_next + next_run = recovered_next logger.info( - "Job '%s' next_run_at offset changed (%s -> %s). " - "Recomputing cron run to preserve local wall-clock intent: %s", + "Job '%s' had no next_run_at; recovering %s run at %s", job.get("name", job.get("id", "?")), - raw_next_run_dt.utcoffset(), - now.utcoffset(), - new_next, + recovery_kind, + recovered_next, ) for rj in raw_jobs: if rj["id"] == job["id"]: - rj["next_run_at"] = new_next + rj["next_run_at"] = recovered_next needs_save = True break - continue - if next_run_dt <= now: + raw_next_run_dt = datetime.fromisoformat(next_run) + schedule = job.get("schedule", {}) + kind = schedule.get("kind") - # For recurring jobs, check if the scheduled time is stale - # (gateway was down and missed the window). Fast-forward to - # the next future occurrence instead of firing a stale run. - grace = _compute_grace_seconds(schedule) - if kind in {"cron", "interval"} and (now - next_run_dt).total_seconds() > grace: - # Job is past its catch-up grace window — skip accumulated - # missed runs but still execute once now to avoid deferring - # indefinitely (e.g. a long-running job just finished). + next_run_dt = _ensure_aware(raw_next_run_dt) + # Migration repair: a cron job persists next_run_at as an absolute + # instant, but the cron expr describes local wall-clock intent. If the + # configured/system timezone changed after persistence, the stored + # instant's offset no longer matches now's, and its converted time can + # look due hours early (21:00+10 -> 13:00+02). When the stored *wall + # clock* is still in the future, recompute from the schedule so we fire + # at the intended local time instead of early-then-again. + # + # TRADE-OFF: this cannot distinguish a config/host TZ migration from a + # legitimate DST offset change. A DST boundary that satisfies all four + # conditions will recompute (and thus SKIP the pending occurrence, no + # catch-up) rather than fire it. Accepted: in the pure-migration case + # the recompute lands on the same wall-clock time later the same period, + # and DST-boundary collisions with a still-future stored wall clock are + # rare relative to the double-fire bug this prevents (#28934). + if ( + kind == "cron" + and next_run_dt <= now + and _timezone_offset_mismatch(raw_next_run_dt, now) + and _stored_wall_clock_is_future(raw_next_run_dt, now) + ): new_next = compute_next_run(schedule, now.isoformat()) if new_next: logger.info( - "Job '%s' missed its scheduled time (%s, grace=%ds). " - "Running now; next run provisionally set to: %s " - "(re-anchored on completion)", + "Job '%s' next_run_at offset changed (%s -> %s). " + "Recomputing cron run to preserve local wall-clock intent: %s", job.get("name", job.get("id", "?")), - next_run, - grace, + raw_next_run_dt.utcoffset(), + now.utcoffset(), new_next, ) - # Persist the fast-forward to storage now (skip accumulated - # slots). In the built-in ticker path this is shortly - # overwritten by advance_next_run + mark_job_run, but it is - # NOT redundant: it (a) protects the crash window between - # here and mark_job_run, and (b) covers the external - # fire_due provider path, which does not call - # advance_next_run. mark_job_run re-anchors next_run_at off - # the actual completion time, so this value is provisional. for rj in raw_jobs: if rj["id"] == job["id"]: rj["next_run_at"] = new_next needs_save = True break - # Fall through to due.append(job) — execute once now + continue - # One-shot dispatch-limit guard (issue #38758): a finite one-shot - # claimed via claim_dispatch() but whose tick died before - # mark_job_run could remove it will have completed >= times while - # still looking due (last_run_at was never written, so the - # recovery helper re-armed it). Remove it instead of re-firing. - if kind == "once": - repeat = job.get("repeat") - if repeat: - times = repeat.get("times") - completed = repeat.get("completed", 0) - if times is not None and times > 0 and completed >= times: + if next_run_dt <= now: + + # For recurring jobs, check if the scheduled time is stale + # (gateway was down and missed the window). Fast-forward to + # the next future occurrence instead of firing a stale run. + grace = _compute_grace_seconds(schedule) + if kind in {"cron", "interval"} and (now - next_run_dt).total_seconds() > grace: + # Job is past its catch-up grace window — skip accumulated + # missed runs but still execute once now to avoid deferring + # indefinitely (e.g. a long-running job just finished). + new_next = compute_next_run(schedule, now.isoformat()) + if new_next: logger.info( - "Job '%s': one-shot dispatch limit reached (%d/%d) " - "— removing stale due entry", + "Job '%s' missed its scheduled time (%s, grace=%ds). " + "Running now; next run provisionally set to: %s " + "(re-anchored on completion)", job.get("name", job.get("id", "?")), - completed, - times, + next_run, + grace, + new_next, ) + # Persist the fast-forward to storage now (skip accumulated + # slots). In the built-in ticker path this is shortly + # overwritten by advance_next_run + mark_job_run, but it is + # NOT redundant: it (a) protects the crash window between + # here and mark_job_run, and (b) covers the external + # fire_due provider path, which does not call + # advance_next_run. mark_job_run re-anchors next_run_at off + # the actual completion time, so this value is provisional. for rj in raw_jobs: if rj["id"] == job["id"]: - raw_jobs.remove(rj) + rj["next_run_at"] = new_next needs_save = True break - continue + # Fall through to due.append(job) — execute once now - # Durably claim a one-shot for the DURATION of its run before - # returning it as due, so a second scheduler process (gateway + - # desktop both run in-process 60s tickers on one HERMES_HOME) - # cannot re-dispatch it while the first run is still in flight - # (#59229). A plain one-shot's due-state is not resolved until - # mark_job_run() completes it minutes later, so advancing - # next_run_at by a fixed window is not enough — a job that outlives - # one tick (e.g. a 2.5-min research prompt) would simply re-fire on - # the next tick after the window. Instead we stamp a run_claim under - # the same lock get_due_jobs already holds; the other process reads - # a fresh claim on its next tick and skips (handled at the top of - # this loop). mark_job_run() clears the claim on completion. The TTL - # is only a safety valve: a claiming tick that DIES mid-run leaves a - # stale claim that expires after the resolved run-claim TTL - # (_oneshot_run_claim_ttl_seconds, derived from HERMES_CRON_TIMEOUT), - # so the job is re-dispatched rather than wedged forever. - if kind == "once": - claim = {"at": now.isoformat(), "by": _machine_id()} - job["run_claim"] = claim - for rj in raw_jobs: - if rj["id"] == job["id"]: - rj["run_claim"] = claim - needs_save = True - break + # One-shot dispatch-limit guard (issue #38758): a finite one-shot + # claimed via claim_dispatch() but whose tick died before + # mark_job_run could remove it will have completed >= times while + # still looking due (last_run_at was never written, so the + # recovery helper re-armed it). Remove it instead of re-firing. + if kind == "once": + repeat = job.get("repeat") + if repeat: + times = repeat.get("times") + completed = repeat.get("completed", 0) + if times is not None and times > 0 and completed >= times: + logger.info( + "Job '%s': one-shot dispatch limit reached (%d/%d) " + "— removing stale due entry", + job.get("name", job.get("id", "?")), + completed, + times, + ) + for rj in raw_jobs: + if rj["id"] == job["id"]: + raw_jobs.remove(rj) + needs_save = True + break + continue - due.append(job) + # Durably claim a one-shot for the DURATION of its run before + # returning it as due, so a second scheduler process (gateway + + # desktop both run in-process 60s tickers on one HERMES_HOME) + # cannot re-dispatch it while the first run is still in flight + # (#59229). A plain one-shot's due-state is not resolved until + # mark_job_run() completes it minutes later, so advancing + # next_run_at by a fixed window is not enough — a job that outlives + # one tick (e.g. a 2.5-min research prompt) would simply re-fire on + # the next tick after the window. Instead we stamp a run_claim under + # the same lock get_due_jobs already holds; the other process reads + # a fresh claim on its next tick and skips (handled at the top of + # this loop). mark_job_run() clears the claim on completion. The TTL + # is only a safety valve: a claiming tick that DIES mid-run leaves a + # stale claim that expires after the resolved run-claim TTL + # (_oneshot_run_claim_ttl_seconds, derived from HERMES_CRON_TIMEOUT), + # so the job is re-dispatched rather than wedged forever. + if kind == "once": + claim = {"at": now.isoformat(), "by": _machine_id()} + job["run_claim"] = claim + for rj in raw_jobs: + if rj["id"] == job["id"]: + rj["run_claim"] = claim + needs_save = True + break + + due.append(job) + except Exception: + logger.exception( + "Skipping malformed cron job %r during due scan", + job.get("name") or job.get("id") or "?", + ) + continue if needs_save: save_jobs(raw_jobs) diff --git a/scripts/release.py b/scripts/release.py index 4957e53a6708..b55f7771647a 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -256,6 +256,7 @@ AUTHOR_MAP = { "dkobi16@gmail.com": "Diyoncrz18", "arnaud@nolimitdevelopment.com": "ali-nld", "sswdarius@gmail.com": "necoweb3", + "bassisho@Mac-mini-bassis.local": "hydracoco7", # PR #61382 salvage (id-less cron job freeze) "t.chen@aftership.com": "cypctlinux", # PR #52403 salvage (Slack bot/workflow auth before no-user-id guard) "30854794+YLChen-007@users.noreply.github.com": "YLChen-007", # PR #26965 (approval remote command substitution) "1078345+egilewski@users.noreply.github.com": "egilewski", # co-author, PR #40663 diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index 0c9eef4c0a94..79536c69397e 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -1503,6 +1503,60 @@ class TestBadNextRunAtRecovery: assert any(j["id"] == "good-sibling" for j in due2) +class TestPerJobScanContainment: + """Structural guard: ANY per-job exception in the due scan must degrade to + skipping that one job for the tick — never abort the scan and starve + healthy siblings (the freeze class behind bad id / schedule / next_run_at). + """ + + def test_unforeseen_per_job_exception_does_not_starve_siblings(self, tmp_cron_dir): + """Simulate a FUTURE malformed-field variant none of the shape + normalizers repair, by making grace computation raise for one job + only. The per-job guard must skip it and still return the sibling.""" + from datetime import timezone, timedelta as td + from unittest.mock import patch as mock_patch + + now = datetime.now(timezone.utc) + past = (now - td(seconds=30)).isoformat() + + poison = { + "id": "poison", + # minutes=7 tags this schedule so the patched helper can target it + "schedule": {"kind": "interval", "minutes": 7}, + "next_run_at": past, + "enabled": True, + "created_at": past, + } + good = { + "id": "good-sibling", + "schedule": {"kind": "interval", "minutes": 5}, + "next_run_at": past, + "enabled": True, + "created_at": past, + } + save_jobs([poison, good]) + + import cron.jobs as jobs_mod + real_grace = jobs_mod._compute_grace_seconds + + def selective_grace(schedule): + if schedule.get("minutes") == 7: + raise RuntimeError("simulated unforeseen malformed field") + return real_grace(schedule) + + with mock_patch.object(jobs_mod, "_compute_grace_seconds", selective_grace): + due = get_due_jobs() # must not raise + + ids = [j["id"] for j in due] + assert "good-sibling" in ids, f"healthy sibling starved: {ids}" + assert "poison" not in ids + + # Scheduler stays alive on subsequent ticks too. + with mock_patch.object(jobs_mod, "_compute_grace_seconds", selective_grace): + due2 = get_due_jobs() + assert any(j["id"] == "good-sibling" for j in due2) + + class TestSaveJobOutput: def test_creates_output_file(self, tmp_cron_dir): output_file = save_job_output("test123", "# Results\nEverything ok.") From b298fd5db1577ab93307d44188f7eae5573a15ea Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:56:50 -0700 Subject: [PATCH 336/610] =?UTF-8?q?test(cli):=20deflake=20--accept-hooks?= =?UTF-8?q?=20position=20test=20=E2=80=94=20one=20driver,=20one=20import?= =?UTF-8?q?=20(#61734)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_accepted_at_every_position spawned 11 separate 'python -m hermes_cli.main' subprocesses, each cold-importing the full CLI module tree under a 15s TimeoutExpired deadline. On a loaded CI worker the import alone can exceed that (slice 2/8 flaked exactly here on PR #61726's run, TimeoutExpired at subprocess.py:1253), failing PRs that never touched the CLI. Replace with ONE driver subprocess that imports hermes_cli.main once and parses all 11 argvs in-process (catching SystemExit per argv), reporting JSON results. Same assertions per argv, identical semantics (verified the --help-before-unknown-flag exit behavior matches the old method), ~11x less import work, and the 180s timeout only trips on a genuine hang. --- .../test_argparse_flag_propagation.py | 59 +++++++++++++++---- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/tests/hermes_cli/test_argparse_flag_propagation.py b/tests/hermes_cli/test_argparse_flag_propagation.py index 87db493850c0..0c62655a6379 100644 --- a/tests/hermes_cli/test_argparse_flag_propagation.py +++ b/tests/hermes_cli/test_argparse_flag_propagation.py @@ -153,7 +153,7 @@ class TestAcceptHooksOnAgentSubparsers: parser and `chat`, so `hermes gateway run --accept-hooks` failed with `unrecognized arguments`.""" - @pytest.mark.parametrize("argv", [ + ARGVS = [ ["--accept-hooks", "gateway", "run", "--help"], ["gateway", "--accept-hooks", "run", "--help"], ["gateway", "run", "--accept-hooks", "--help"], @@ -165,20 +165,57 @@ class TestAcceptHooksOnAgentSubparsers: ["mcp", "--accept-hooks", "serve", "--help"], ["mcp", "serve", "--accept-hooks", "--help"], ["acp", "--accept-hooks", "--help"], - ]) - def test_accepted_at_every_position(self, argv): - """Invoking `hermes <argv>` must exit 0 (help) rather than - failing with `unrecognized arguments`.""" + ] + + # One driver subprocess parses ALL argvs: hermes_cli.main is a very heavy + # import (previously 11 separate `python -m hermes_cli.main` spawns with a + # 15s timeout each — a cold import on a loaded CI worker regularly blew + # that deadline, making this test flaky). Importing once and parsing 11 + # times removes the repeated-import cost entirely; the generous timeout + # only trips on a genuine hang. `--help` exits via SystemExit(0), which + # the driver catches per argv. + _DRIVER = r""" +import io, json, sys +from contextlib import redirect_stdout, redirect_stderr + +import hermes_cli.main as main_mod + +argvs = json.loads(sys.argv[1]) +results = [] +for argv in argvs: + sys.argv = ["hermes", *argv] + out, err = io.StringIO(), io.StringIO() + code = 0 + try: + with redirect_stdout(out), redirect_stderr(err): + main_mod.main() + except SystemExit as exc: + code = int(exc.code or 0) + except Exception as exc: # noqa: BLE001 - report, don't crash the driver + code = -1 + err.write(repr(exc)) + results.append({"argv": argv, "code": code, "stderr": err.getvalue()[:300]}) +print(json.dumps(results)) +""" + + def test_accepted_at_every_position(self): + """Every `hermes <argv>` must exit 0 (help) rather than failing + with `unrecognized arguments`.""" + import json import subprocess result = subprocess.run( - [sys.executable, "-m", "hermes_cli.main", *argv], + [sys.executable, "-c", self._DRIVER, json.dumps(self.ARGVS)], capture_output=True, text=True, - timeout=15, + timeout=180, ) assert result.returncode == 0, ( - f"argv={argv!r} returned {result.returncode}\n" - f"stdout: {result.stdout[:300]}\n" - f"stderr: {result.stderr[:300]}" + f"driver failed rc={result.returncode}\n" + f"stdout: {result.stdout[:500]}\nstderr: {result.stderr[:500]}" ) - assert "unrecognized arguments" not in result.stderr + for entry in json.loads(result.stdout.strip().splitlines()[-1]): + assert entry["code"] == 0, ( + f"argv={entry['argv']!r} returned {entry['code']}\n" + f"stderr: {entry['stderr']}" + ) + assert "unrecognized arguments" not in entry["stderr"] From 549b87c9aa53fa091d65309b5e0861bf61bfc740 Mon Sep 17 00:00:00 2001 From: AlexFucuson9 <AlexFucuson9@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:00:11 +0700 Subject: [PATCH 337/610] fix(gateway): prevent hygiene compression from destroying archived transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When gateway session-hygiene auto-compression fires with in-place compaction, the flow was: 1. _compress_context() calls archive_and_compact() — soft-archives old rows (active=0, compacted=1) and inserts compacted messages as the new active set. This is the non-destructive, durable path. 2. The hygiene handler then called rewrite_transcript() — which calls replace_messages(active_only=False) — DELETEing ALL rows including the just-archived turns. Silent permanent data loss (#61145). The interactive /compress handler had the same bug. Fix: only call rewrite_transcript() when session rotation produced a new session id (legacy path). When in-place compaction succeeded, skip the rewrite — archive_and_compact() already handled persistence. Closes #61145. --- gateway/run.py | 24 ++++++++++++++++++-- gateway/slash_commands.py | 46 +++++++++++++++++++++++---------------- 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 54f38d1bb795..313d9a14825b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -11240,7 +11240,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) # Only rewrite the transcript when rotation produced - # a NEW session id OR in-place compaction succeeded. + # a NEW session id. In-place compaction does NOT + # need a rewrite: archive_and_compact() has already + # soft-archived the previous active rows and inserted + # the compacted messages as the new active set inside + # _compress_context(). Calling rewrite_transcript() + # after in-place compaction would invoke + # replace_messages(active_only=False) which DELETEs + # ALL rows — including the archived turns that + # archive_and_compact() deliberately preserved + # (silent data loss, #61145). + # # The danger this guards against (mirrors the # /compress fix #44794/#39704): if _compress_context # returns a summary but neither rotates nor completes @@ -11249,7 +11259,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # rewrite_transcript() would DELETE the original # messages and replace them with only the compressed # summary (permanent data loss, #21301). - if _hyg_rotated or _hyg_in_place: + if _hyg_rotated: self.session_store.rewrite_transcript( session_entry.session_id, _compressed ) @@ -11260,6 +11270,16 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _new_tokens = estimate_messages_tokens_rough( _compressed ) + elif _hyg_in_place: + # archive_and_compact() already persisted the + # compacted transcript inside _compress_context. + # Reset counts to match the new active set. + session_entry.last_prompt_tokens = 0 + history = _compressed + _new_count = len(_compressed) + _new_tokens = estimate_messages_tokens_rough( + _compressed + ) else: # No rewrite happened — transcript preserved # unchanged, so the post-compression counts equal diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 3d7bed924c7b..aab3b733ae24 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3262,18 +3262,23 @@ class GatewaySlashCommandsMixin: # at it; in place the original transcript is untouched) and lets # the outer handler surface a "compress failed" banner instead. # - # The rewrite runs when EITHER rotation produced a new id OR - # in-place compaction succeeded. It is skipped in the THIRD - # case: _compress_context could NOT rotate AND was not in-place - # (e.g. legacy mode but _session_db unavailable / the DB split - # raised) — there session_id is unchanged for a FAILURE reason, - # and rewrite_transcript() would DELETE the original messages and - # replace them with only the compressed summary (permanent data - # loss #44794, #39704). In in-place mode the unchanged id is - # SUCCESS, so the rewrite is exactly right (and is the durable - # write when the throwaway /compress agent has no _session_db of - # its own). - if rotated or _in_place: + # Only rewrite the transcript when rotation produced a NEW + # session id. In-place compaction does NOT need a rewrite: + # archive_and_compact() has already soft-archived the previous + # active rows and inserted the compacted messages as the new + # active set inside _compress_context(). Calling + # rewrite_transcript() after in-place compaction would invoke + # replace_messages(active_only=False) which DELETEs ALL rows — + # including the archived turns that archive_and_compact() + # deliberately preserved (silent data loss, #61145). + # + # The third case: _compress_context could NOT rotate AND was + # not in-place (e.g. legacy mode but _session_db unavailable / + # the DB split raised) — there session_id is unchanged for a + # FAILURE reason, and rewrite_transcript() would DELETE the + # original messages and replace them with only the compressed + # summary (permanent data loss #44794, #39704). + if rotated: if not self.session_store.rewrite_transcript( new_session_id, compressed ): @@ -3281,13 +3286,16 @@ class GatewaySlashCommandsMixin: f"failed to persist compressed transcript for " f"session {new_session_id}" ) - if rotated: - session_entry.session_id = new_session_id - self.session_store._save() - await asyncio.to_thread( - self._sync_telegram_topic_binding, - source, session_entry, reason="compress-command", - ) + session_entry.session_id = new_session_id + self.session_store._save() + await asyncio.to_thread( + self._sync_telegram_topic_binding, + source, session_entry, reason="compress-command", + ) + elif _in_place: + # archive_and_compact() already persisted the compacted + # transcript inside _compress_context — nothing to do. + pass else: logger.warning( "Manual /compress: session rotation did not occur " From 9cbac6418b933e6b3a0850d4f48149604ecab95d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:52:36 -0700 Subject: [PATCH 338/610] test(gateway): pin in-place compaction skipping the destructive rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip the two tests that pinned the old buggy behavior (rewrite_transcript called after in-place compaction) to assert the corrected invariant from #61145: archive_and_compact() already persisted, so the handler must NOT call rewrite_transcript — its replace_messages(active_only=False) would DELETE the just-archived rows. E2E-verified against a real SessionDB: 6 soft-archived rows are wiped by replace_messages' default path, confirming the data-loss premise. --- tests/gateway/test_compress_command.py | 28 +++++++++++++++++--------- tests/gateway/test_session_hygiene.py | 7 ++++--- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/tests/gateway/test_compress_command.py b/tests/gateway/test_compress_command.py index e8e8d5828876..4cf51429cac9 100644 --- a/tests/gateway/test_compress_command.py +++ b/tests/gateway/test_compress_command.py @@ -369,11 +369,14 @@ async def test_compress_command_does_not_repoint_session_when_transcript_write_f @pytest.mark.asyncio -async def test_compress_command_in_place_write_failure_reports_error(): - """In-place compaction (compression.in_place / #38763) does not rotate the - session_id, so a failed rewrite_transcript would leave the DB untouched - while the handler reported success. The write failure must surface as a - failure banner, not a false "Compressed" success.""" +async def test_compress_command_in_place_skips_destructive_rewrite(): + """In-place compaction (compression.in_place / #38763) persists via + archive_and_compact() inside _compress_context — the previous active rows + are soft-archived and the compacted set inserted. Calling + rewrite_transcript() afterwards would invoke + replace_messages(active_only=False), DELETEing the just-archived rows + (silent data loss, #61145). The handler must skip the rewrite and still + report success.""" history = _make_history() compressed = [ history[0], @@ -383,7 +386,7 @@ async def test_compress_command_in_place_write_failure_reports_error(): runner = _make_runner(history) runner._session_db = object() session_entry = runner.session_store.get_or_create_session.return_value - runner.session_store.rewrite_transcript = MagicMock(return_value=False) + runner.session_store.rewrite_transcript = MagicMock() agent_instance = MagicMock() agent_instance.shutdown_memory_provider = MagicMock() @@ -397,7 +400,11 @@ async def test_compress_command_in_place_write_failure_reports_error(): agent_instance._compress_context.return_value = (compressed, "") def _estimate(messages, **_kwargs): - return 100 + if messages == history: + return 100 + if messages == compressed: + return 60 + raise AssertionError(f"unexpected transcript: {messages!r}") with ( patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "***"}), @@ -407,10 +414,11 @@ async def test_compress_command_in_place_write_failure_reports_error(): ): result = await runner._handle_compress_command(_make_event()) - assert "failed" in result.lower() - assert "Compressed:" not in result + assert "Compressed:" in result + # The destructive rewrite must NOT run — archive_and_compact() already + # persisted, and rewrite_transcript would wipe the archived rows. + runner.session_store.rewrite_transcript.assert_not_called() assert session_entry.session_id == "sess-1" - runner.session_store._save.assert_not_called() agent_instance.shutdown_memory_provider.assert_called_once() agent_instance.close.assert_called_once() diff --git a/tests/gateway/test_session_hygiene.py b/tests/gateway/test_session_hygiene.py index abad2b49ef74..7545665cfef8 100644 --- a/tests/gateway/test_session_hygiene.py +++ b/tests/gateway/test_session_hygiene.py @@ -950,9 +950,10 @@ async def test_session_hygiene_forces_in_place_compaction_with_bound_session_db( agent = FakeInPlaceCompressAgent.last_instance assert agent is not None agent.context_compressor.bind_session_state.assert_called_once_with(fake_db, "sess-1") - runner.session_store.rewrite_transcript.assert_called_once_with( - "sess-1", [{"role": "assistant", "content": "compressed in place"}] - ) + # In-place compaction already persisted via archive_and_compact() — + # rewrite_transcript would replace_messages(active_only=False) and DELETE + # the just-archived rows (#61145). The hygiene handler must skip it. + runner.session_store.rewrite_transcript.assert_not_called() runner._run_agent.assert_awaited_once() From 79f12748022817a7c4f3fee747e45e9e6979214a Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:26:32 -0700 Subject: [PATCH 339/610] chore: AUTHOR_MAP entry for AlexFucuson9 (PR #61209 salvage) His noreply email has no numeric-id+ prefix, so the attribution CI's auto-resolve pattern doesn't match it. --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index b55f7771647a..cfd36bb9476f 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -257,6 +257,7 @@ AUTHOR_MAP = { "arnaud@nolimitdevelopment.com": "ali-nld", "sswdarius@gmail.com": "necoweb3", "bassisho@Mac-mini-bassis.local": "hydracoco7", # PR #61382 salvage (id-less cron job freeze) + "AlexFucuson9@users.noreply.github.com": "AlexFucuson9", # PR #61209 salvage (hygiene compression data loss) "t.chen@aftership.com": "cypctlinux", # PR #52403 salvage (Slack bot/workflow auth before no-user-id guard) "30854794+YLChen-007@users.noreply.github.com": "YLChen-007", # PR #26965 (approval remote command substitution) "1078345+egilewski@users.noreply.github.com": "egilewski", # co-author, PR #40663 From b5226caff8eeca575ea64a4c68dee5dc84b1e50f Mon Sep 17 00:00:00 2001 From: Adam Biggs <email@adambig.gs> Date: Wed, 10 Jun 2026 15:42:55 -0700 Subject: [PATCH 340/610] fix(memory): share one SQLite connection per holographic store database Every MemoryStore instance opened its own SQLite connection guarded by its own RLock. Several providers coexist in one process (the main agent plus every delegate_task subagent), so instances pointing at the same memory_store.db raced as independent WAL writers. Combined with writes that were not rolled back on error, one connection could leave an open write transaction that pinned the write lock and made every other connection's writes fail with "database is locked" for the full busy timeout. Instances for the same database now share ONE process-wide connection and ONE re-entrant lock, so access is fully serialized and cross-connection contention is impossible. The shared connection is refcounted: closing one instance never tears it out from under a live sibling, and the last close releases it. The connection runs in autocommit (isolation_level=None) so a write that raises mid-method can never leave a dangling transaction holding the write lock; the existing explicit commit() calls become harmless no-ops. The provider's shutdown() now calls the refcount-guarded close() instead of just dropping the reference: leaving finalization to GC kept the connection (and its write lock) alive indefinitely on long-running gateways, prolonging the exact contention this fix removes. The last provider now releases the connection deterministically while siblings stay live; regression tests fail without the wiring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- plugins/memory/holographic/__init__.py | 12 +- plugins/memory/holographic/store.py | 73 +++++- .../plugins/memory/test_holographic_store.py | 225 ++++++++++++++++++ 3 files changed, 294 insertions(+), 16 deletions(-) create mode 100644 tests/plugins/memory/test_holographic_store.py diff --git a/plugins/memory/holographic/__init__.py b/plugins/memory/holographic/__init__.py index fa8dae97618e..7e2e387902e0 100644 --- a/plugins/memory/holographic/__init__.py +++ b/plugins/memory/holographic/__init__.py @@ -251,12 +251,12 @@ class HolographicMemoryProvider(MemoryProvider): logger.debug("Holographic memory_write mirror failed: %s", e) def shutdown(self) -> None: - # Close the SQLite connection deterministically instead of leaking it - # to GC. MemoryStore opens its connection with check_same_thread=False - # (store.py), so without an explicit close() the sqlite3.Connection's - # fd is released by refcount/GC at a non-deterministic time on a - # non-deterministic thread, churning a DB fd through the kernel's free - # pool on every session teardown. close() already exists and is cheap. + # Release the shared SQLite connection deterministically on the + # caller's thread. Dropping the reference alone leaves fd finalization + # to GC, which keeps the connection (and its write lock) alive on a + # long-running gateway and prolongs the "database is locked" contention + # this store's shared-connection refcounting is meant to eliminate. + # close() is idempotent and refcount-guarded, so siblings stay safe. if self._store is not None: try: self._store.close() diff --git a/plugins/memory/holographic/store.py b/plugins/memory/holographic/store.py index 7ffc7db0f199..a521b865d6b6 100644 --- a/plugins/memory/holographic/store.py +++ b/plugins/memory/holographic/store.py @@ -98,6 +98,21 @@ def _clamp_trust(value: float) -> float: class MemoryStore: """SQLite-backed fact store with entity resolution and trust scoring.""" + # --- Process-wide shared connection registry ------------------------- + # SQLite permits only one writer at a time. Each MemoryStore instance used + # to open its own connection guarded by its own RLock, so the several + # providers that coexist in one process (the main agent plus every + # delegate_task subagent) raced as independent WAL writers. Combined with + # writes that were not rolled back on error, one connection could leave an + # open write transaction that pinned the write lock and made every other + # connection's write fail with "database is locked" for the full busy + # timeout. All instances for the same database now share ONE connection and + # ONE re-entrant lock, so access is fully serialized and cross-connection + # contention is impossible. The shared connection is refcounted, so closing + # one instance never tears the connection out from under a live sibling. + _shared: dict = {} + _shared_guard = threading.Lock() + def __init__( self, db_path: "str | Path | None" = None, @@ -112,14 +127,35 @@ class MemoryStore: self.default_trust = _clamp_trust(default_trust) self.hrr_dim = hrr_dim self._hrr_available = hrr._HAS_NUMPY - self._conn: sqlite3.Connection = sqlite3.connect( - str(self.db_path), - check_same_thread=False, - timeout=10.0, - ) - self._lock = threading.RLock() - self._conn.row_factory = sqlite3.Row - self._init_db() + + # Acquire (or open) the process-wide shared connection for this DB. + self._key = str(self.db_path) + with MemoryStore._shared_guard: + entry = MemoryStore._shared.get(self._key) + if entry is None: + conn = sqlite3.connect( + self._key, + check_same_thread=False, + timeout=10.0, + # Autocommit: every statement is its own transaction, so a + # write that raises mid-method can never leave a dangling + # transaction (and its write lock) open. The explicit + # commit() calls below become harmless no-ops. + isolation_level=None, + ) + conn.row_factory = sqlite3.Row + entry = {"conn": conn, "lock": threading.RLock(), "refs": 0, "ready": False} + MemoryStore._shared[self._key] = entry + entry["refs"] += 1 + self._entry = entry + self._conn = entry["conn"] + self._lock = entry["lock"] + + # Initialise the schema once per shared connection. + with self._lock: + if not self._entry["ready"]: + self._init_db() + self._entry["ready"] = True # ------------------------------------------------------------------ # Initialisation @@ -575,8 +611,25 @@ class MemoryStore: return dict(row) def close(self) -> None: - """Close the database connection.""" - self._conn.close() + """Release this instance's reference to the shared connection. + + The underlying connection is closed only when the last MemoryStore + referencing the same database is closed, so closing one instance can + never break sibling instances that still hold it. Idempotent. + """ + if getattr(self, "_entry", None) is None: + return + with MemoryStore._shared_guard: + entry = self._entry + if entry is None: + return + entry["refs"] -= 1 + if entry["refs"] <= 0: + try: + entry["conn"].close() + finally: + MemoryStore._shared.pop(self._key, None) + self._entry = None def __enter__(self) -> "MemoryStore": return self diff --git a/tests/plugins/memory/test_holographic_store.py b/tests/plugins/memory/test_holographic_store.py new file mode 100644 index 000000000000..59d8ee1cb818 --- /dev/null +++ b/tests/plugins/memory/test_holographic_store.py @@ -0,0 +1,225 @@ +"""Tests for the holographic MemoryStore shared-connection registry. + +MemoryStore instances pointing at the same database file must share one +process-wide SQLite connection and one re-entrant lock. Multiple providers +coexist in a single process (the main agent plus every delegate_task +subagent); when each instance owned a private connection they raced as +independent WAL writers and intermittently failed with "database is locked". + +Covers: connection sharing/refcounting, close() semantics, cross-instance +visibility, concurrent multi-instance writers, and write-lock release after +a failed write. +""" + +import sqlite3 +import threading + +import pytest + +from plugins.memory.holographic.store import MemoryStore + + +@pytest.fixture(autouse=True) +def _clean_shared_registry(): + """Each test starts and ends with an empty shared-connection registry.""" + # Drop any leakage from earlier tests in the same process. + for entry in list(MemoryStore._shared.values()): + try: + entry["conn"].close() + except sqlite3.Error: + pass + MemoryStore._shared.clear() + yield + leaked = list(MemoryStore._shared) + for entry in list(MemoryStore._shared.values()): + try: + entry["conn"].close() + except sqlite3.Error: + pass + MemoryStore._shared.clear() + assert not leaked, f"test leaked shared connections: {leaked}" + + +@pytest.fixture +def db_path(tmp_path): + return tmp_path / "memory_store.db" + + +class TestSharedConnection: + def test_same_path_shares_one_connection(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + try: + assert a._conn is b._conn + assert a._lock is b._lock + assert len(MemoryStore._shared) == 1 + assert MemoryStore._shared[str(a.db_path)]["refs"] == 2 + finally: + a.close() + b.close() + + def test_different_paths_get_distinct_connections(self, tmp_path): + a = MemoryStore(tmp_path / "one.db") + b = MemoryStore(tmp_path / "two.db") + try: + assert a._conn is not b._conn + assert len(MemoryStore._shared) == 2 + finally: + a.close() + b.close() + + def test_writes_visible_across_instances(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + try: + fact_id = a.add_fact("Hermes likes shared connections", category="test") + facts = b.list_facts(category="test") + assert [f["fact_id"] for f in facts] == [fact_id] + finally: + a.close() + b.close() + + def test_schema_initialised_once_per_connection(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) # must not re-run schema init / WAL probe + try: + assert MemoryStore._shared[str(a.db_path)]["ready"] is True + b.add_fact("schema still works") + finally: + a.close() + b.close() + + +class TestCloseSemantics: + def test_closing_one_instance_keeps_sibling_alive(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + a.close() + try: + # The shared connection must survive the sibling's close(). + fact_id = b.add_fact("survivor write") + assert fact_id > 0 + finally: + b.close() + + def test_last_close_releases_connection(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + conn = a._conn + a.close() + b.close() + assert MemoryStore._shared == {} + with pytest.raises(sqlite3.ProgrammingError): + conn.execute("SELECT 1") + + def test_close_is_idempotent(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + a.close() + a.close() # double close must not steal b's reference + try: + b.add_fact("still alive after double close") + assert MemoryStore._shared[str(b.db_path)]["refs"] == 1 + finally: + b.close() + + def test_context_manager_releases_reference(self, db_path): + with MemoryStore(db_path) as store: + store.add_fact("context managed") + assert MemoryStore._shared == {} + + def test_reopen_after_full_close(self, db_path): + with MemoryStore(db_path) as store: + store.add_fact("first lifetime") + with MemoryStore(db_path) as store: + facts = store.list_facts() + assert [f["content"] for f in facts] == ["first lifetime"] + + +class TestConcurrency: + def test_concurrent_multi_instance_writers(self, db_path): + """Many instances writing from many threads must never hit + 'database is locked' — the failure mode of per-instance connections.""" + n_threads, n_facts = 8, 15 + errors: list[BaseException] = [] + + def writer(idx: int) -> None: + store = MemoryStore(db_path) + try: + for i in range(n_facts): + store.add_fact(f"fact thread={idx} seq={i}", category="load") + except BaseException as exc: # noqa: BLE001 - recorded for assert + errors.append(exc) + finally: + store.close() + + threads = [threading.Thread(target=writer, args=(i,)) for i in range(n_threads)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"concurrent writers failed: {errors[:3]}" + with MemoryStore(db_path) as store: + facts = store.list_facts(category="load", limit=500) + assert len(facts) == n_threads * n_facts + assert MemoryStore._shared == {} + + def test_failed_write_does_not_pin_write_lock(self, db_path, monkeypatch): + """A write that raises mid-method must not leave an open transaction + holding the SQLite write lock (autocommit isolation_level=None).""" + broken = MemoryStore(db_path) + sibling = MemoryStore(db_path) + try: + monkeypatch.setattr( + MemoryStore, + "_rebuild_bank", + lambda self, category: (_ for _ in ()).throw(RuntimeError("boom")), + ) + with pytest.raises(RuntimeError, match="boom"): + broken.add_fact("write that fails after the INSERT") + monkeypatch.undo() + + # No dangling transaction: the connection reports autocommit state + # and the sibling can write immediately. + assert broken._conn.in_transaction is False + sibling.add_fact("sibling write right after the failure") + finally: + broken.close() + sibling.close() + + +class TestProviderShutdown: + """The provider's shutdown() must release its shared connection, not just + drop the reference. Leaving finalization to GC keeps the connection (and + its write lock) alive on a long-running gateway, which is exactly the + "database is locked" contention the shared-connection registry removes.""" + + def test_shutdown_releases_shared_connection(self, db_path): + from plugins.memory.holographic import HolographicMemoryProvider + + provider = HolographicMemoryProvider(config={"db_path": str(db_path)}) + provider.initialize("session-shutdown") + assert MemoryStore._shared[str(db_path)]["refs"] == 1 + + provider.shutdown() + + assert provider._store is None + assert MemoryStore._shared == {} + + def test_shutdown_keeps_sibling_provider_alive(self, db_path): + from plugins.memory.holographic import HolographicMemoryProvider + + a = HolographicMemoryProvider(config={"db_path": str(db_path)}) + b = HolographicMemoryProvider(config={"db_path": str(db_path)}) + a.initialize("session-a") + b.initialize("session-b") + assert MemoryStore._shared[str(db_path)]["refs"] == 2 + + a.shutdown() + # Sibling still holds a live, writable connection. + assert MemoryStore._shared[str(db_path)]["refs"] == 1 + assert b._store is not None + b._store.add_fact("write after sibling shutdown") + b.shutdown() + assert MemoryStore._shared == {} From a801046669657f117dfb3f3ce7f12ad94dfa87b2 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:21:15 -0700 Subject: [PATCH 341/610] fix(memory): resolve() the shared-connection registry key; symlink test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups for salvaged PR #43819: the registry key was str(Path(db_path).expanduser()) — a symlinked or relative path to the same DB file got its own connection, silently reintroducing the exact multi-writer contention the registry prevents. Key on Path.resolve() (OSError-tolerant fallback). Adds a symlink regression test and the AUTHOR_MAP entry for adambiggs. --- plugins/memory/holographic/store.py | 8 +++++++- scripts/release.py | 1 + tests/plugins/memory/test_holographic_store.py | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/plugins/memory/holographic/store.py b/plugins/memory/holographic/store.py index a521b865d6b6..bf58d7a22dbc 100644 --- a/plugins/memory/holographic/store.py +++ b/plugins/memory/holographic/store.py @@ -129,7 +129,13 @@ class MemoryStore: self._hrr_available = hrr._HAS_NUMPY # Acquire (or open) the process-wide shared connection for this DB. - self._key = str(self.db_path) + # resolve() (not just expanduser) so symlinked/relative paths to the + # same file share ONE connection instead of silently reintroducing + # the multi-writer contention this registry exists to prevent. + try: + self._key = str(self.db_path.resolve()) + except OSError: + self._key = str(self.db_path) with MemoryStore._shared_guard: entry = MemoryStore._shared.get(self._key) if entry is None: diff --git a/scripts/release.py b/scripts/release.py index cfd36bb9476f..6b3f9043baf7 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -258,6 +258,7 @@ AUTHOR_MAP = { "sswdarius@gmail.com": "necoweb3", "bassisho@Mac-mini-bassis.local": "hydracoco7", # PR #61382 salvage (id-less cron job freeze) "AlexFucuson9@users.noreply.github.com": "AlexFucuson9", # PR #61209 salvage (hygiene compression data loss) + "email@adambig.gs": "adambiggs", # PR #43819 salvage (holographic shared SQLite connection) "t.chen@aftership.com": "cypctlinux", # PR #52403 salvage (Slack bot/workflow auth before no-user-id guard) "30854794+YLChen-007@users.noreply.github.com": "YLChen-007", # PR #26965 (approval remote command substitution) "1078345+egilewski@users.noreply.github.com": "egilewski", # co-author, PR #40663 diff --git a/tests/plugins/memory/test_holographic_store.py b/tests/plugins/memory/test_holographic_store.py index 59d8ee1cb818..df351864b001 100644 --- a/tests/plugins/memory/test_holographic_store.py +++ b/tests/plugins/memory/test_holographic_store.py @@ -68,6 +68,24 @@ class TestSharedConnection: a.close() b.close() + def test_symlinked_path_shares_connection(self, tmp_path): + """A symlink to the same DB file must hit the same registry entry — + otherwise two connections to one file silently reintroduce the + multi-writer contention the registry exists to prevent.""" + real_dir = tmp_path / "real" + real_dir.mkdir() + link_dir = tmp_path / "link" + link_dir.symlink_to(real_dir) + + a = MemoryStore(real_dir / "memory_store.db") + b = MemoryStore(link_dir / "memory_store.db") + try: + assert a._conn is b._conn + assert len(MemoryStore._shared) == 1 + finally: + a.close() + b.close() + def test_writes_visible_across_instances(self, db_path): a = MemoryStore(db_path) b = MemoryStore(db_path) From 0a4b4d6df52f9a7ba9ae5914c5035b171d129f47 Mon Sep 17 00:00:00 2001 From: AlexFucuson9 <AlexFucuson9@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:33:13 +0700 Subject: [PATCH 342/610] fix(agent): reapply provider headers after model switch switch_model() rebuilds _client_kwargs from scratch (api_key + base_url) but does not call _apply_client_headers_for_base_url(), so provider- specific headers like OpenRouter HTTP-Referer and X-Title are lost. Subsequent requests show "Unknown" in OpenRouter dashboard logs. Call _apply_client_headers_for_base_url() after rebuilding _client_kwargs and before creating the new client. Fixes #61099 --- agent/agent_runtime_helpers.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 14c7c73321da..bf4d31b4f4d4 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1953,6 +1953,11 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo _sm_timeout = get_provider_request_timeout(agent.provider, agent.model) if _sm_timeout is not None: agent._client_kwargs["timeout"] = _sm_timeout + # Reapply provider-specific headers (e.g. OpenRouter HTTP-Referer, + # X-Title) that were lost when _client_kwargs was rebuilt from + # scratch. Without this, model switches clear attribution headers + # and OpenRouter logs show "Unknown" for subsequent requests. + agent._apply_client_headers_for_base_url(effective_base) agent.client = agent._create_openai_client( dict(agent._client_kwargs), reason="switch_model", From c75789f2453cb1e2f98bb305dec2ae29a0922581 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:37:07 -0700 Subject: [PATCH 343/610] test: regression coverage for header reapplication on model switch Three tests for the #61099 salvage: OpenRouter attribution headers present after switching to openrouter.ai, Kimi User-Agent sentinel present after switching to api.kimi.com, and stale headers cleared when switching to a provider with no URL-specific headers. 2/3 fail on unpatched main (DID NOT ATTACH), confirming the bug. --- .../test_switch_model_reapplies_headers.py | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/run_agent/test_switch_model_reapplies_headers.py diff --git a/tests/run_agent/test_switch_model_reapplies_headers.py b/tests/run_agent/test_switch_model_reapplies_headers.py new file mode 100644 index 000000000000..cd24bb622bad --- /dev/null +++ b/tests/run_agent/test_switch_model_reapplies_headers.py @@ -0,0 +1,100 @@ +"""Regression tests for #61099: switch_model must reapply provider-specific +default headers when it rebuilds _client_kwargs from scratch. + +Without _apply_client_headers_for_base_url() in the rebuild path, a /model +switch drops OpenRouter attribution headers (HTTP-Referer / X-Title → logs +show "Unknown") and, worse, functional headers like Kimi's User-Agent +sentinel (403 without it). +""" + +from unittest.mock import MagicMock, patch + +from run_agent import AIAgent +from agent.context_compressor import ContextCompressor + + +def _make_agent(provider="copilot", base_url="https://api.githubcopilot.com") -> AIAgent: + """Minimal AIAgent with a context_compressor, skipping __init__.""" + agent = AIAgent.__new__(AIAgent) + + agent.model = "claude-opus-4.8" + agent.provider = provider + agent.base_url = base_url + agent.api_key = "sk-primary" + agent.api_mode = "chat_completions" + agent.client = MagicMock() + agent.quiet_mode = True + agent._config_context_length = None + agent._client_kwargs = {"api_key": "sk-primary", "base_url": base_url} + + compressor = ContextCompressor( + model=agent.model, + threshold_percent=0.50, + base_url=base_url, + api_key="sk-primary", + provider=provider, + quiet_mode=True, + config_context_length=None, + ) + agent.context_compressor = compressor + agent._primary_runtime = {} + + return agent + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_to_openrouter_reapplies_attribution_headers(mock_ctx_len): + """Switching to an openrouter.ai base_url must attach the OpenRouter + attribution headers (HTTP-Referer / X-Title) to the rebuilt client + kwargs — not ship a bare api_key+base_url client (#61099).""" + agent = _make_agent(provider="copilot", base_url="https://api.githubcopilot.com") + + agent.switch_model( + "deepseek/deepseek-chat", + "openrouter", + api_key="sk-or-new", + base_url="https://openrouter.ai/api/v1", + ) + + headers = agent._client_kwargs.get("default_headers") or {} + assert "HTTP-Referer" in headers + assert headers.get("X-Title") + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_to_kimi_reapplies_user_agent_sentinel(mock_ctx_len): + """Kimi requires a User-Agent sentinel; a switch to api.kimi.com must + carry it or every request 403s.""" + agent = _make_agent(provider="openrouter", base_url="https://openrouter.ai/api/v1") + + agent.switch_model( + "kimi-k2", + "kimi", + api_key="sk-kimi", + base_url="https://api.kimi.com/v1", + ) + + headers = agent._client_kwargs.get("default_headers") or {} + assert headers.get("User-Agent", "").startswith("claude-code/") + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_away_from_headered_provider_clears_stale_headers(mock_ctx_len): + """Switching FROM a headered provider TO one with no URL-specific headers + must not carry the old provider's headers along.""" + agent = _make_agent(provider="openrouter", base_url="https://openrouter.ai/api/v1") + agent._client_kwargs["default_headers"] = { + "HTTP-Referer": "https://hermes-agent.nousresearch.com", + "X-Title": "Hermes Agent", + } + + agent.switch_model( + "MiniMax-M3", + "custom:minimax", + api_key="sk-minimax", + base_url="https://api.minimax.io/v1", + ) + + headers = agent._client_kwargs.get("default_headers") or {} + assert "HTTP-Referer" not in headers + assert "X-Title" not in headers From bd16395255024e7114f23b8b4274b5b4d788eea0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:42:20 -0700 Subject: [PATCH 344/610] chore: add AlexFucuson9 to AUTHOR_MAP (PR #61347 salvage) --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 6b3f9043baf7..39b1f4318ee7 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -55,6 +55,7 @@ AUTHOR_MAP = { "ishengeqi@163.com": "isheng-eqi", # PR #59428 salvage (cron: reject past one-shot timestamps in update_job fallback + resume_job; #59395). Also PR #59446 salvage (cron: advance one-shot next_run_at before dispatch so concurrent gateway+desktop schedulers can't double-execute; #59229). "derek2000139@qq.com": "derek2000139", # PR #57838 salvage (desktop/windows: pre-write update marker before quit dwell so the renderer's waitForUpdateToFinish gate parks instead of respawning a backend that re-locks venv .pyd files mid-update) "AndreasHiltner@users.noreply.github.com": "AndreasHiltner", # PR #56854 salvage (gateway: route multiplex profile responses through the profile's own adapter — 53-site _adapter_for_source sweep) + "AlexFucuson9@users.noreply.github.com": "AlexFucuson9", # PR #61347 salvage (agent: reapply provider headers after model switch; #61099) "allenliang2022@users.noreply.github.com": "allenliang2022", # PR #56932 test coverage folded into #56909 salvage (408 → retryable timeout) "m888.braun@hotmail.com": "ManniBr", # PR #57417 partial salvage (gateway: fail-closed adapter resolution for unregistered secondary profiles) "poowis2011@hotmail.com": "Umi4Life", # PR #47377 salvage (agent: emit one-shot fallback switch notice on successful fallback so gateway users see model/provider change; #35419) From bdecf0ab944d00da7e05641dfb8e528b29bd1174 Mon Sep 17 00:00:00 2001 From: yungchentang <46495124+yungchentang@users.noreply.github.com> Date: Sat, 4 Jul 2026 06:56:55 -0700 Subject: [PATCH 345/610] fix(cli): ignore empty config sections --- cli.py | 2 ++ tests/hermes_cli/test_config_env_expansion.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/cli.py b/cli.py index 069b3c616c1e..379ca15df1ac 100644 --- a/cli.py +++ b/cli.py @@ -543,6 +543,8 @@ def load_cli_config() -> Dict[str, Any]: if key == "model": continue # Already handled above if key in file_config: + if isinstance(defaults[key], dict) and file_config[key] is None: + continue if isinstance(defaults[key], dict) and isinstance(file_config[key], dict): defaults[key].update(file_config[key]) else: diff --git a/tests/hermes_cli/test_config_env_expansion.py b/tests/hermes_cli/test_config_env_expansion.py index acc41da7c46a..75ef62592d1a 100644 --- a/tests/hermes_cli/test_config_env_expansion.py +++ b/tests/hermes_cli/test_config_env_expansion.py @@ -150,6 +150,18 @@ class TestLoadConfigCacheEnvStaleness: class TestLoadCliConfigExpansion: """Verify that load_cli_config() also expands ${VAR} references.""" + def test_cli_config_ignores_empty_terminal_section(self, tmp_path, monkeypatch): + config_file = tmp_path / "config.yaml" + config_file.write_text("terminal:\n") + + monkeypatch.setattr("cli._hermes_home", tmp_path) + + from cli import load_cli_config + config = load_cli_config() + + assert isinstance(config["terminal"], dict) + assert config["terminal"]["env_type"] == "local" + def test_cli_config_expands_auxiliary_api_key(self, tmp_path, monkeypatch): config_yaml = ( "auxiliary:\n" From 46613071e4bf509db141b63930229287d036effa Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:37:45 -0700 Subject: [PATCH 346/610] fix(config): widen empty-section guard to _deep_merge in load_config Sibling site of the load_cli_config fix (#58277): _deep_merge treated a YAML-null section (terminal: with no value) as an override, replacing the entire DEFAULT_CONFIG dict for that section with None. Every downstream consumer expecting a mapping was a latent crash, and default sub-keys were silently lost. A None override of a dict default is now ignored, matching the CLI loader's behavior. Scalar-null overrides are unchanged. --- hermes_cli/config.py | 8 ++++++++ tests/hermes_cli/test_config.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 153fc5fb6061..981533f15e7e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -6243,6 +6243,12 @@ def _deep_merge(base: dict, override: dict) -> dict: Keys in *override* take precedence. If both values are dicts the merge recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will keep the default ``tts.elevenlabs.model_id`` intact. + + An empty section key in config.yaml (``terminal:`` with no value) parses + as YAML ``None``; treating that as an override would replace the entire + default dict with ``None`` and crash every downstream consumer that + expects a mapping (#58277). A ``None`` override of a dict default is + ignored — same as the key being absent. """ result = base.copy() for key, value in override.items(): @@ -6252,6 +6258,8 @@ def _deep_merge(base: dict, override: dict) -> dict: and isinstance(value, dict) ): result[key] = _deep_merge(result[key], value) + elif key in result and isinstance(result[key], dict) and value is None: + continue else: result[key] = value return result diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index dfc1c457cd39..0b1f1404bab9 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -360,6 +360,34 @@ class TestLoadConfigParseFailure: assert capsys.readouterr().err == "" +class TestEmptyConfigSections: + """Empty section keys (``terminal:`` with no value) parse as YAML None + and must not replace the default dict for that section (#58277).""" + + def test_null_section_keeps_defaults_in_load_config(self, tmp_path): + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + (tmp_path / "config.yaml").write_text( + "model:\n default: test/custom\n" + "terminal:\n" + "display:\n" + ) + config = load_config() + assert config["model"]["default"] == "test/custom" + assert isinstance(config["terminal"], dict) + assert config["terminal"] == DEFAULT_CONFIG["terminal"] + assert isinstance(config["display"], dict) + + def test_null_override_of_non_dict_default_still_applies(self, tmp_path): + """None only shields dict defaults — explicit null for a scalar + key remains an override (unchanged behavior).""" + from hermes_cli.config import _deep_merge + + merged = _deep_merge({"scalar": 5, "section": {"a": 1}}, + {"scalar": None, "section": None}) + assert merged["scalar"] is None + assert merged["section"] == {"a": 1} + + class TestSaveAndLoadRoundtrip: @staticmethod def _deny_config_reads(config_path): From 50c66b2f8ef488cdcb9afb27922c41766c69398d Mon Sep 17 00:00:00 2001 From: sharziki <sharvil.saxena@gmail.com> Date: Sat, 6 Jun 2026 18:43:40 -0400 Subject: [PATCH 347/610] fix(gateway): ignore malformed config sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GatewayConfig.from_dict(), PlatformConfig.from_dict(), SessionResetPolicy.from_dict(), and StreamingConfig.from_dict() assumed their input sections were mappings. A malformed scalar from legacy gateway.json or an internal caller could crash config loading with AttributeError before env overrides/defaults had a chance to recover. Coerce non-mapping sections to empty dicts, skip malformed platform entries, and keep valid sibling platform configs loading normally. Tests cover scalar platform blocks, scalar nested reset/streaming sections, and malformed PlatformConfig home_channel/extra values. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --- gateway/config.py | 28 +++++++++++++++------ tests/gateway/test_config.py | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 8c467196e3b5..ce8b30181028 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -130,6 +130,11 @@ def _coerce_optional_positive_int(value: Any, key: str) -> Optional[int]: return parsed +def _coerce_dict(value: Any) -> Dict[str, Any]: + """Return *value* when it is a mapping, otherwise an empty dict.""" + return value if isinstance(value, dict) else {} + + def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str: """Normalize unauthorized DM behavior to a supported value.""" if isinstance(value, str): @@ -383,6 +388,7 @@ class SessionResetPolicy: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy": + data = _coerce_dict(data) # Handle both missing keys and explicit null values (YAML null → None) mode = data.get("mode") at_hour = data.get("at_hour") @@ -492,24 +498,26 @@ class PlatformConfig: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": + data = _coerce_dict(data) home_channel = None - if "home_channel" in data: + if isinstance(data.get("home_channel"), dict): home_channel = HomeChannel.from_dict(data["home_channel"]) # gateway_restart_notification may be bridged into extra via the # shared-key loop in load_gateway_config(); check both top-level # and extra so YAML ``discord: gateway_restart_notification: false`` # works without needing a separate platforms: block. + extra = _coerce_dict(data.get("extra", {})) _grn = data.get("gateway_restart_notification") if _grn is None: - _grn = data.get("extra", {}).get("gateway_restart_notification") + _grn = extra.get("gateway_restart_notification") # typing_indicator mirrors gateway_restart_notification: it may arrive # top-level or bridged into extra by the shared-key loop in # load_gateway_config(), so check both. _typing = data.get("typing_indicator") if _typing is None: - _typing = data.get("extra", {}).get("typing_indicator") + _typing = extra.get("typing_indicator") channel_overrides: Dict[str, ChannelOverride] = {} raw_overrides = data.get("channel_overrides") or {} @@ -527,7 +535,7 @@ class PlatformConfig: gateway_restart_notification=_coerce_bool(_grn, True), typing_indicator=_coerce_bool(_typing, True), channel_overrides=channel_overrides, - extra=data.get("extra", {}), + extra=extra, ) @@ -586,7 +594,7 @@ class StreamingConfig: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "StreamingConfig": - if not data: + if not isinstance(data, dict) or not data: return cls() return cls( enabled=_coerce_bool(data.get("enabled"), False), @@ -823,8 +831,12 @@ class GatewayConfig: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": + data = _coerce_dict(data) platforms = {} - for platform_name, platform_data in data.get("platforms", {}).items(): + platforms_data = _coerce_dict(data.get("platforms", {})) + for platform_name, platform_data in platforms_data.items(): + if not isinstance(platform_data, dict): + continue try: platform = Platform(platform_name) platforms[platform] = PlatformConfig.from_dict(platform_data) @@ -832,11 +844,11 @@ class GatewayConfig: pass # Skip unknown platforms reset_by_type = {} - for type_name, policy_data in data.get("reset_by_type", {}).items(): + for type_name, policy_data in _coerce_dict(data.get("reset_by_type", {})).items(): reset_by_type[type_name] = SessionResetPolicy.from_dict(policy_data) reset_by_platform = {} - for platform_name, policy_data in data.get("reset_by_platform", {}).items(): + for platform_name, policy_data in _coerce_dict(data.get("reset_by_platform", {})).items(): try: platform = Platform(platform_name) reset_by_platform[platform] = SessionResetPolicy.from_dict(policy_data) diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 6dc246e83db4..aa51c2fba541 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -142,6 +142,21 @@ class TestChannelOverride: assert d["system_prompt"] == "Hi" +class TestPlatformConfigMalformedSections: + def test_from_dict_ignores_malformed_nested_sections(self): + restored = PlatformConfig.from_dict( + { + "enabled": True, + "home_channel": "telegram:123", + "extra": "oops", + } + ) + + assert restored.enabled is True + assert restored.home_channel is None + assert restored.extra == {} + + class TestGetConnectedPlatforms: def test_returns_enabled_with_token(self): config = GatewayConfig( @@ -238,6 +253,12 @@ class TestSessionResetPolicy: restored = SessionResetPolicy.from_dict({"notify": "false"}) assert restored.notify is False + def test_from_dict_malformed_section_falls_back_to_defaults(self): + restored = SessionResetPolicy.from_dict("oops") + assert restored.mode == SessionResetPolicy().mode + assert restored.at_hour == 4 + assert restored.idle_minutes == 1440 + class TestStreamingConfig: def test_defaults_to_auto_transport(self): @@ -263,6 +284,11 @@ class TestStreamingConfig: assert restored.buffer_threshold == 24 assert restored.fresh_final_after_seconds == 0.0 + def test_from_dict_malformed_section_falls_back_to_defaults(self): + restored = StreamingConfig.from_dict("enabled") + assert restored.enabled is False + assert restored.transport == "auto" + class TestGatewayConfigRoundtrip: def test_full_roundtrip(self): @@ -365,6 +391,27 @@ class TestGatewayConfigRoundtrip: restored = GatewayConfig.from_dict({"always_log_local": "false"}) assert restored.always_log_local is False + def test_from_dict_ignores_malformed_nested_sections(self): + restored = GatewayConfig.from_dict( + { + "platforms": { + "telegram": "enabled", + "discord": {"enabled": True, "token": "tok"}, + }, + "default_reset_policy": "daily", + "reset_by_type": ["oops"], + "reset_by_platform": "oops", + "streaming": "enabled", + } + ) + + assert Platform.TELEGRAM not in restored.platforms + assert restored.platforms[Platform.DISCORD].enabled is True + assert restored.default_reset_policy.mode == SessionResetPolicy().mode + assert restored.reset_by_type == {} + assert restored.reset_by_platform == {} + assert restored.streaming.transport == "auto" + def test_get_notice_delivery_defaults_to_public(self): config = GatewayConfig( platforms={Platform.SLACK: PlatformConfig(enabled=True, token="***")} From a7f65e3bcd937cd095ba599ab5927af2093a0d95 Mon Sep 17 00:00:00 2001 From: sharziki <sharvil.saxena@gmail.com> Date: Sat, 6 Jun 2026 18:45:43 -0400 Subject: [PATCH 348/610] fix(gateway): tolerate scalar gateway config block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming fallback path read yaml_cfg.get("gateway", {}).get("streaming") when top-level streaming was absent or malformed. If a user accidentally set gateway to a scalar value, config loading crashed with AttributeError instead of ignoring the malformed block and using defaults. Read the gateway block once, verify it is a mapping before accessing nested streaming, and keep the existing gateway.platforms fallback using the same checked value. Adds a regression test for config.yaml containing gateway: disabled. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --- gateway/config.py | 9 +++++++-- tests/gateway/test_config.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index ce8b30181028..87f1c7014788 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1039,6 +1039,8 @@ def load_gateway_config() -> GatewayConfig: if "stt_echo_transcripts" in yaml_cfg: gw_data["stt_echo_transcripts"] = yaml_cfg["stt_echo_transcripts"] + gateway_cfg = yaml_cfg.get("gateway") + if "group_sessions_per_user" in yaml_cfg: gw_data["group_sessions_per_user"] = yaml_cfg["group_sessions_per_user"] @@ -1066,7 +1068,11 @@ def load_gateway_config() -> GatewayConfig: if not isinstance(streaming_cfg, dict): # Fall back to nested gateway.streaming written by # ``hermes config set gateway.streaming.*`` - streaming_cfg = yaml_cfg.get("gateway", {}).get("streaming") + streaming_cfg = ( + gateway_cfg.get("streaming") + if isinstance(gateway_cfg, dict) + else None + ) if isinstance(streaming_cfg, dict): gw_data["streaming"] = streaming_cfg @@ -1099,7 +1105,6 @@ def load_gateway_config() -> GatewayConfig: # ``gateway.platforms`` are loaded the same way as top-level # ``platforms``. Merge nested first so top-level config keeps # precedence, matching the existing gateway.streaming fallback. - gateway_cfg = yaml_cfg.get("gateway") gateway_platforms = gateway_cfg.get("platforms") if isinstance(gateway_cfg, dict) else None platforms_data = gw_data.setdefault("platforms", {}) if not isinstance(platforms_data, dict): diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index aa51c2fba541..46ceeb205c03 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -608,6 +608,18 @@ class TestLoadGatewayConfig: assert config.max_concurrent_sessions == 2 + def test_scalar_gateway_section_does_not_crash_streaming_fallback(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text("gateway: disabled\n", encoding="utf-8") + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.streaming.transport == "auto" + def test_bridges_discord_thread_require_mention_from_config_yaml(self, tmp_path, monkeypatch): """discord.thread_require_mention in config.yaml should reach the runtime env var.""" hermes_home = tmp_path / ".hermes" From 276542c729c10ff9d093760897f4c2d1256a79ce Mon Sep 17 00:00:00 2001 From: luxuguang-leo <luxuguangno1@163.com> Date: Fri, 26 Jun 2026 16:04:05 +0800 Subject: [PATCH 349/610] fix(qqbot): add is_reconnect param to QQAdapter.connect for gateway reconnect compat The base adapter's signature was updated to include , which the reconnect watcher passes as during reconnection. All other platform adapters were updated, but QQAdapter was missed, causing: TypeError: QQAdapter.connect() got an unexpected keyword argument 'is_reconnect' This leads to an infinite retry loop since every reconnect attempt fails immediately with the same TypeError. Fix: add to QQAdapter.connect()'s signature. QQBot has no server-side update queue, so the flag is accepted only for interface conformance. Test: new test_connect_accepts_is_reconnect_param verifies both adapter.connect() and adapter.connect(is_reconnect=True) succeed without raising. --- gateway/platforms/qqbot/adapter.py | 12 ++++++++++-- tests/gateway/test_qqbot.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 2639ab52fdd3..865a38063a1d 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -278,8 +278,16 @@ class QQAdapter(BasePlatformAdapter): # Connection lifecycle # ------------------------------------------------------------------ - async def connect(self) -> bool: - """Authenticate, obtain gateway URL, and open the WebSocket.""" + async def connect(self, *, is_reconnect: bool = False) -> bool: + """ + Authenticate, obtain gateway URL, and open the WebSocket. + + Args: + is_reconnect: False on a cold first boot; True when the + reconnect watcher is re-establishing this platform after + an outage. QQBot has no server-side update queue so this + flag is accepted for interface conformance only. + """ if not AIOHTTP_AVAILABLE: message = "QQ startup failed: aiohttp not installed" self._set_fatal_error("qq_missing_dependency", message, retryable=True) diff --git a/tests/gateway/test_qqbot.py b/tests/gateway/test_qqbot.py index d250a119e741..562db57193d8 100644 --- a/tests/gateway/test_qqbot.py +++ b/tests/gateway/test_qqbot.py @@ -190,6 +190,21 @@ class TestVoiceAttachmentSSRFProtection: assert kwargs.get("follow_redirects") is True assert kwargs.get("event_hooks", {}).get("response") == [_ssrf_redirect_guard] + def test_connect_accepts_is_reconnect_param(self): + """connect() must accept is_reconnect for interface conformance with + the base adapter, which the reconnect watcher calls with + is_reconnect=True.""" + from gateway.platforms.qqbot import QQAdapter + + adapter = QQAdapter(_make_config(app_id="a", client_secret="b")) + adapter._ensure_token = mock.AsyncMock(side_effect=RuntimeError("stop after client init")) + + # Both forms must not raise TypeError. + connected_default = asyncio.run(adapter.connect()) + connected_explicit = asyncio.run(adapter.connect(is_reconnect=True)) + assert connected_default is False + assert connected_explicit is False + # --------------------------------------------------------------------------- # WebSocket proxy handling From 0f8603c571beb6c944bcdb9fa2d45f8f85aaa750 Mon Sep 17 00:00:00 2001 From: lemonwan <lemonwan@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:25:55 +0800 Subject: [PATCH 350/610] test(gateway): regression: every adapter.connect() must accept is_reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway reconnect watcher forwards is_reconnect=True to every adapter.connect() call on every retry. Adapters whose signature omits the kwarg raise TypeError at every reconnect attempt and stay silently disconnected — the exact bug that shipped for QQAdapter and only surfaced after messages stopped flowing on the QQ channel for hours. This test statically parses every adapter.py under gateway/platforms/ and plugins/platforms/ (via AST, so third-party SDKs like slack_sdk, matrix-nio, aiohttp, telegram, etc. are NOT required in the test env) and asserts every *Adapter class with an async connect() accepts is_reconnect — either as a keyword-only argument or absorbed by **kwargs. Also fixes plugins/platforms/wecom/callback_adapter.py:WecomCallbackAdapter, which the new test caught as a second offender. Same class of bug: bare 'async def connect(self)' signature would die on the first reconnect. Companion to #59429 (which fixed the original QQAdapter offender). --- plugins/platforms/wecom/callback_adapter.py | 8 +- ...t_adapter_connect_is_reconnect_contract.py | 157 ++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 tests/gateway/test_adapter_connect_is_reconnect_contract.py diff --git a/plugins/platforms/wecom/callback_adapter.py b/plugins/platforms/wecom/callback_adapter.py index e7e7931b7b8d..e358397249a5 100644 --- a/plugins/platforms/wecom/callback_adapter.py +++ b/plugins/platforms/wecom/callback_adapter.py @@ -115,7 +115,13 @@ class WecomCallbackAdapter(BasePlatformAdapter): # Lifecycle # ------------------------------------------------------------------ - async def connect(self) -> bool: + async def connect(self, *, is_reconnect: bool = False) -> bool: + # ``is_reconnect`` is forwarded by GatewayRunner on every retry per + # the BasePlatformAdapter.connect contract. Callback adapters have + # no server-side queue to preserve, so the flag is accepted-and- + # ignored — but the kwarg MUST be present or the reconnect watcher + # dies with TypeError and the platform silently stays offline. + del is_reconnect if not self._apps: logger.warning("[WecomCallback] No callback apps configured") return False diff --git a/tests/gateway/test_adapter_connect_is_reconnect_contract.py b/tests/gateway/test_adapter_connect_is_reconnect_contract.py new file mode 100644 index 000000000000..3421948748c9 --- /dev/null +++ b/tests/gateway/test_adapter_connect_is_reconnect_contract.py @@ -0,0 +1,157 @@ +"""Regression: every platform adapter's ``connect()`` must accept the +``is_reconnect`` keyword-only argument. + +The gateway reconnect watcher forwards ``is_reconnect=True`` to every +adapter on every retry (see ``GatewayRunner._call_adapter_connect`` in +``gateway/run.py``). An adapter whose ``connect()`` signature omits +``is_reconnect`` blows up on the first reconnect attempt with:: + + TypeError: <Foo>Adapter.connect() got an unexpected + keyword argument 'is_reconnect' + +…and never recovers, leaving that platform silently disconnected until +the operator manually restarts the gateway. This exact bug shipped for +``QQAdapter`` and was only discovered after messages stopped flowing on +the QQ channel for hours. + +To prevent this class of bug from regressing, we statically parse every +``adapter.py`` under ``gateway/platforms/`` and ``plugins/platforms/`` +and assert that its ``connect()`` method accepts an ``is_reconnect`` +keyword. Doing this via AST (rather than importing) avoids pulling every +platform's optional third-party SDK (aiohttp, slack_sdk, telegram, +matrix-nio, etc.) into the test environment. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# Directories that hold platform adapters. Each entry is a directory +# whose immediate children are either ``adapter.py`` files or +# sub-packages that expose one. +ADAPTER_ROOTS = [ + REPO_ROOT / "gateway" / "platforms", + REPO_ROOT / "plugins" / "platforms", +] + + +def _iter_adapter_files() -> list[Path]: + """Every ``*adapter*.py`` under the two adapter roots. + + We intentionally cast a wide net (any ``adapter.py`` / ``*_adapter.py`` + inside these trees) so a new platform can't sneak in without the + contract check firing. + """ + files: list[Path] = [] + for root in ADAPTER_ROOTS: + if not root.is_dir(): + continue + for path in root.rglob("*.py"): + if path.name == "adapter.py" or path.stem.endswith("_adapter"): + files.append(path) + return sorted(files) + + +def _find_adapter_classes(module: ast.Module) -> list[ast.ClassDef]: + """Classes that look like a platform adapter. + + Heuristic: any class whose name ends in ``Adapter`` and that defines + an ``async def connect`` method. This catches every subclass of + ``BasePlatformAdapter`` in the tree today (QQAdapter, TelegramAdapter, + SlackAdapter, …) without importing the base class. + """ + hits: list[ast.ClassDef] = [] + for node in ast.walk(module): + if not isinstance(node, ast.ClassDef): + continue + if not node.name.endswith("Adapter"): + continue + has_connect = any( + isinstance(item, ast.AsyncFunctionDef) and item.name == "connect" + for item in node.body + ) + if has_connect: + hits.append(node) + return hits + + +def _connect_accepts_is_reconnect(cls: ast.ClassDef) -> bool: + """True iff the class's own ``connect()`` accepts ``is_reconnect``. + + Accepts the kwarg via: + - keyword-only argument named ``is_reconnect`` (the canonical form + used by ``BasePlatformAdapter``), OR + - ``**kwargs`` catch-all (also safe — the kwarg is absorbed). + """ + for item in cls.body: + if not (isinstance(item, ast.AsyncFunctionDef) and item.name == "connect"): + continue + args = item.args + if any(a.arg == "is_reconnect" for a in args.kwonlyargs): + return True + if any(a.arg == "is_reconnect" for a in args.args): + return True + if args.kwarg is not None: # **kwargs + return True + return False + return False + + +ADAPTER_FILES = _iter_adapter_files() + + +def test_adapter_discovery_finds_platforms(): + """Sanity: the discovery walker actually found a meaningful set of + adapters. If this drops to a trivial number, the glob broke and the + contract test below is silently passing on nothing. + """ + assert len(ADAPTER_FILES) >= 20, ( + f"Expected to discover >=20 platform adapter files under " + f"{[str(p) for p in ADAPTER_ROOTS]}, found {len(ADAPTER_FILES)}. " + f"The discovery glob is likely broken." + ) + + +@pytest.mark.parametrize( + "adapter_file", + ADAPTER_FILES, + ids=lambda p: str(p.relative_to(REPO_ROOT)), +) +def test_adapter_connect_accepts_is_reconnect(adapter_file: Path): + """Every ``*Adapter.connect()`` must accept ``is_reconnect``. + + This is the contract enforced by ``BasePlatformAdapter.connect`` and + relied on by ``GatewayRunner._call_adapter_connect``. Violating it + silently disables the affected platform after its first reconnect. + """ + source = adapter_file.read_text(encoding="utf-8") + try: + tree = ast.parse(source, filename=str(adapter_file)) + except SyntaxError as exc: + pytest.fail(f"Could not parse {adapter_file}: {exc}") + + classes = _find_adapter_classes(tree) + if not classes: + pytest.skip( + f"{adapter_file.relative_to(REPO_ROOT)} has no *Adapter class " + f"with an async connect() — nothing to check." + ) + + offenders = [cls.name for cls in classes if not _connect_accepts_is_reconnect(cls)] + + assert not offenders, ( + f"{adapter_file.relative_to(REPO_ROOT)}: the following adapter " + f"class(es) define `async def connect()` WITHOUT accepting the " + f"`is_reconnect` kwarg: {offenders}. " + f"Add `*, is_reconnect: bool = False` to the signature " + f"(matching BasePlatformAdapter.connect). " + f"The gateway reconnect watcher forwards this kwarg on every " + f"retry — an adapter that rejects it silently disconnects after " + f"the first outage." + ) From 9c40fc2f2c74aa95836d848d93b01be5e1cbc575 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:46:47 -0700 Subject: [PATCH 351/610] chore: map QQBot fix contributor --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 39b1f4318ee7..293669fe1f50 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "luxuguangno1@163.com": "luxuguang-leo", # PR #52966 salvage (QQBot reconnect contract) "grace@weeb.onl": "evelynburger", # PR #57544 salvage (gateway: webhook payload filters + route scripts; commit under unlinked identity) "contato@siteup.com.br": "SiteupAgencia", # PR #57435 salvage (tui_gateway: back off notification poller when session is busy; #55578) "164521089+rainbowgits@users.noreply.github.com": "rainbowgore", # PR #59405 salvage (mcp: bound stdio initialize handshake to stop subprocess/FD leak; #59349) From 16b841b7f497545af1a3d71b2d4b0df14f1bc921 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:48:44 -0700 Subject: [PATCH 352/610] docs: add gateway reconnect contract infographic --- .../infographic.png | Bin 0 -> 1632053 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 infographic/gateway-reconnect-contract/infographic.png diff --git a/infographic/gateway-reconnect-contract/infographic.png b/infographic/gateway-reconnect-contract/infographic.png new file mode 100644 index 0000000000000000000000000000000000000000..11d36ab235d3504c0fd4e1c5b4d006c0fd9e5022 GIT binary patch literal 1632053 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�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 J^PZUb$LpvE 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 #*WM_el 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 PBlv?mpGt@AV*CFC?eB=} literal 0 HcmV?d00001 From 540f90190f50f9518bf36632a724e0e58877a10b Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:53:39 -0700 Subject: [PATCH 353/610] chore: map reconnect contract contributor --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 293669fe1f50..3fffedcfb479 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "lemonwan@users.noreply.github.com": "lemonwan", # PR #59430 sibling salvage (adapter reconnect contract guard) "luxuguangno1@163.com": "luxuguang-leo", # PR #52966 salvage (QQBot reconnect contract) "grace@weeb.onl": "evelynburger", # PR #57544 salvage (gateway: webhook payload filters + route scripts; commit under unlinked identity) "contato@siteup.com.br": "SiteupAgencia", # PR #57435 salvage (tui_gateway: back off notification poller when session is busy; #55578) From b5c655c89e5ce8a91328bff5aa8c79180299d440 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:43:18 -0700 Subject: [PATCH 354/610] fix(cli): normalize role into a single CSS token for the message class Addresses Copilot review on #61348: the HTML-escaped role, while safe from injection (quotes are escaped), still contains whitespace when a crafted role is supplied, which splits the class attribute into several unintended CSS classes. Keep the escaped role for the display badge, and reduce the raw role to a single safe CSS token (alnum/-/_) for the class name. Real roles (user/assistant/system/tool) are unchanged, so the existing .message-<role> rules still match. --- hermes_cli/session_export_html.py | 14 ++++- .../test_session_export_html_escape.py | 56 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 tests/hermes_cli/test_session_export_html_escape.py diff --git a/hermes_cli/session_export_html.py b/hermes_cli/session_export_html.py index 2489cf653a76..b35a70907f84 100644 --- a/hermes_cli/session_export_html.py +++ b/hermes_cli/session_export_html.py @@ -682,8 +682,16 @@ def _generate_messages_html(messages: List[Dict[str, Any]]) -> str: content_parts.append(str(part)) content = "\n".join(content_parts) - # Build message HTML - msg_class = f"message message-{role} active" + # Build message HTML. The role feeds two sinks and for tool/MCP messages + # is externally influenced, so treat each sink on its own terms: + # - display text: HTML-escape (prevents markup/JS injection). + # - class attribute: reduce to a single safe CSS token (alnum/-/_), + # so a crafted role can neither break out of the attribute nor split + # into several unintended classes. Real roles (user/assistant/system/ + # tool) are unchanged, so the `.message-<role>` rules still match. + safe_role = _escape_html(role) + role_class = "".join(c if c.isalnum() or c in "-_" else "-" for c in str(role).lower()) + msg_class = f"message message-{role_class} active" # Delay animation for initial items delay_style = f' style="animation-delay: {min(i * 0.05, 1.0)}s"' if i < 10 else "" @@ -691,7 +699,7 @@ def _generate_messages_html(messages: List[Dict[str, Any]]) -> str: html = f'<div class="{msg_class}"{delay_style}>' html += f' <div class="message-header">' - html += f' <div class="role-badge">{chevron_html} {role_icon} {role}</div>' + html += f' <div class="role-badge">{chevron_html} {role_icon} {safe_role}</div>' html += f' <div class="timestamp">{timestamp}</div>' html += ' </div>' html += ' <div class="message-body">' diff --git a/tests/hermes_cli/test_session_export_html_escape.py b/tests/hermes_cli/test_session_export_html_escape.py new file mode 100644 index 000000000000..8de3c61b23d8 --- /dev/null +++ b/tests/hermes_cli/test_session_export_html_escape.py @@ -0,0 +1,56 @@ +import re + +from hermes_cli.session_export_html import _generate_messages_html + + +def test_tool_call_name_is_escaped_in_html_export(): + messages = [ + { + "role": "assistant", + "content": "", + "timestamp": 1700000000, + "tool_calls": [ + { + "function": { + "name": "<script>alert(1)</script>", + "arguments": "{}", + } + } + ], + } + ] + + html = _generate_messages_html(messages) + + # Raw, executable markup must never reach the standalone artifact. + assert "<script>alert(1)</script>" not in html + # The escaped form must be present instead. + assert "<script>alert(1)</script>" in html + + +def test_role_is_escaped_in_html_export(): + messages = [ + { + "role": "<img src=x onerror=alert(document.domain)>", + "content": "hello", + "timestamp": 1700000000, + } + ] + + html = _generate_messages_html(messages) + + assert "<img src=x onerror=alert(document.domain)>" not in html + assert "<img src=x onerror=alert(document.domain)>" in html + # The class attribute must remain a single, well-formed token: a crafted + # role must not break out of it nor split into several unintended classes. + class_value = re.search(r'class="(message message-[^"]*active)"', html) + assert class_value is not None + assert " message-" in class_value.group(1) # exactly one message-<role> class + assert class_value.group(1).count("message-") == 1 + + +def test_known_role_keeps_its_css_class(): + html = _generate_messages_html( + [{"role": "assistant", "content": "hi", "timestamp": 1700000000}] + ) + assert 'class="message message-assistant active"' in html From a4dd08a977f50672cad5173299bfa0914c30499d Mon Sep 17 00:00:00 2001 From: Koho Zheng <koho.jung@outlook.com> Date: Fri, 10 Jul 2026 06:05:24 +0800 Subject: [PATCH 355/610] fix(session-export): escape html tool call names --- hermes_cli/session_export_html.py | 40 +++++++++++++++++-------- tests/hermes_cli/test_session_export.py | 40 +++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/hermes_cli/session_export_html.py b/hermes_cli/session_export_html.py index b35a70907f84..24a5a2bcac0b 100644 --- a/hermes_cli/session_export_html.py +++ b/hermes_cli/session_export_html.py @@ -8,7 +8,9 @@ Enhanced with UI-UX-PRO-MAX design intelligence. import json import datetime +import secrets from typing import Any, Dict, List +from urllib.parse import quote # --- Icons (Lucide-style SVGs) --- ICON_USER = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' @@ -26,6 +28,7 @@ HTML_TEMPLATE = """<!DOCTYPE html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-{script_nonce}'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'"> <title>{page_title} @@ -566,13 +569,13 @@ HTML_TEMPLATE = """
-