From 2aa34c54848c94afac8ba52607f2bb95b728dc87 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 28 Jul 2026 11:06:14 -0700 Subject: [PATCH 1/8] fix(observability): export shared metrics after tasks Signed-off-by: Alex Fournier --- .../observability/relay_shared_metrics.py | 12 ++-- hermes_cli/observability/shared_metrics.py | 28 ++++++++ tests/hermes_cli/test_relay_shared_metrics.py | 64 +++++++++++++++++ .../test_relay_shared_metrics_runtime.py | 70 ++++++++++++++++--- 4 files changed, 160 insertions(+), 14 deletions(-) diff --git a/hermes_cli/observability/relay_shared_metrics.py b/hermes_cli/observability/relay_shared_metrics.py index fb6f885b2f05..2274f8cd4df7 100644 --- a/hermes_cli/observability/relay_shared_metrics.py +++ b/hermes_cli/observability/relay_shared_metrics.py @@ -351,7 +351,10 @@ class _Runtime: with session.lock: if session.closing: return - self._finish_task(session, task_id, event) + finished = self._finish_task(session, task_id, event) + if finished: + self._safe(self.relay.subscribers.flush) + self._export() def close_session(self, event: dict[str, Any]) -> None: session = self._session(event) @@ -560,10 +563,10 @@ class _Runtime: session: _MetricsSession, task_id: str, event: dict[str, Any], - ) -> None: + ) -> bool: task = session.tasks.get(task_id) if task is None: - return + return False self._end_pending_model_calls(session, {**event, "task_id": task_id}) fields = task_terminal_fields( {**task.start_fields, **event}, @@ -592,9 +595,10 @@ class _Runtime: turn_key = (session.session_id, turn_id) if self._turn_sessions.get(turn_key) is session: self._turn_sessions.pop(turn_key, None) + return True def _export(self) -> None: - self._safe(self.subscriber.store.create_and_export_package) + self._safe(self.subscriber.store.create_and_export_package_if_due) def _event_metadata(self) -> dict[str, str]: return { diff --git a/hermes_cli/observability/shared_metrics.py b/hermes_cli/observability/shared_metrics.py index 506fb4ab1314..666053e0d19d 100644 --- a/hermes_cli/observability/shared_metrics.py +++ b/hermes_cli/observability/shared_metrics.py @@ -114,6 +114,14 @@ class SharedMetricsStore: for _ in range(pending_periods): if self._create_package() is None: break + return self._export_and_prune() + + def create_and_export_package_if_due(self) -> list[Path]: + """Create pending packages at most once per UTC day, then export them.""" + self._create_pending_packages_if_due() + return self._export_and_prune() + + def _export_and_prune(self) -> list[Path]: exported = self._export_pending_packages() try: self._prune_expired_history() @@ -281,6 +289,26 @@ class SharedMetricsStore: ).fetchone() return int(row["period_count"]) if row is not None else 0 + def _create_pending_packages_if_due(self) -> None: + now = _utc_now() + with self._connection() as connection: + with write_txn(connection): + # Gate on the committed package, not its file write, so a failed + # outbox export can be retried without packaging deltas twice. + package_created_today = connection.execute( + """ + SELECT 1 + FROM package_outbox + WHERE substr(created_at, 1, 10) >= ? + LIMIT 1 + """, + (now.date().isoformat(),), + ).fetchone() + if package_created_today is not None: + return + while self._create_package_in_transaction(connection, now) is not None: + pass + def _create_package(self) -> dict[str, Any] | None: now = _utc_now() with self._connection() as connection: diff --git a/tests/hermes_cli/test_relay_shared_metrics.py b/tests/hermes_cli/test_relay_shared_metrics.py index 058e07db7bc7..573835be9845 100644 --- a/tests/hermes_cli/test_relay_shared_metrics.py +++ b/tests/hermes_cli/test_relay_shared_metrics.py @@ -18,6 +18,7 @@ from types import SimpleNamespace from typing import Any import pytest +from hermes_cli.observability import shared_metrics as shared_metrics_module from hermes_cli.observability.shared_metrics import SharedMetricsStore from hermes_cli.observability.shared_metrics_contract import ( COUNT_BUCKETS, @@ -142,6 +143,43 @@ def test_model_call_counter_survives_restart_and_exports_only_new_deltas(tmp_pat assert restarted.counter_snapshot()[0]["packaged_value"] == 3 +def test_due_export_runs_once_per_utc_day_and_catches_up_pending_deltas( + tmp_path, monkeypatch +): + current_time = datetime(2026, 7, 28, 9, tzinfo=timezone.utc) + monkeypatch.setattr(shared_metrics_module, "_utc_now", lambda: current_time) + store = SharedMetricsStore(tmp_path / "metrics.sqlite3", tmp_path / "outbox") + + store.record_model_call(_dimensions(), "test-version") + assert len(store.create_and_export_package_if_due()) == 1 + + current_time = datetime(2026, 7, 28, 18, tzinfo=timezone.utc) + store = SharedMetricsStore(tmp_path / "metrics.sqlite3", tmp_path / "outbox") + store.record_model_call(_dimensions(), "test-version") + assert store.create_and_export_package_if_due() == [] + assert len(list((tmp_path / "outbox").glob("*.json"))) == 1 + assert store.counter_snapshot()[0] == { + "period_start": "2026-07-28", + "metric_name": "hermes.model_call.count", + "hermes_version": "test-version", + "dimensions": _dimensions(), + "value": 2, + "packaged_value": 1, + } + + current_time = datetime(2026, 7, 29, 9, tzinfo=timezone.utc) + store.record_model_call(_dimensions(), "test-version") + assert len(store.create_and_export_package_if_due()) == 2 + assert len(list((tmp_path / "outbox").glob("*.json"))) == 3 + assert all( + row["value"] == row["packaged_value"] for row in store.counter_snapshot() + ) + + store.record_model_call(_dimensions(), "test-version") + assert store.create_and_export_package_if_due() == [] + assert len(list((tmp_path / "outbox").glob("*.json"))) == 3 + + def test_package_schema_matches_the_model_call_contract(): properties = _package_dimension_schema()["properties"] @@ -758,6 +796,32 @@ def test_concurrent_package_builders_commit_one_delta(tmp_path): assert store.counter_snapshot()[0]["packaged_value"] == 1 +def test_concurrent_due_exports_create_one_daily_package(tmp_path): + database_path = tmp_path / "metrics.sqlite3" + outbox_directory = tmp_path / "outbox" + store = SharedMetricsStore(database_path, outbox_directory) + store.record_model_call(_dimensions(), "test-version") + ready = threading.Barrier(8) + + def export() -> None: + worker_store = SharedMetricsStore(database_path, outbox_directory) + ready.wait(timeout=5) + worker_store.create_and_export_package_if_due() + + with ThreadPoolExecutor(max_workers=8) as executor: + futures = [executor.submit(export) for _ in range(8)] + for future in futures: + future.result() + + with sqlite3.connect(database_path) as connection: + [outbox_count] = connection.execute( + "SELECT COUNT(*) FROM package_outbox" + ).fetchone() + assert outbox_count == 1 + assert len(list(outbox_directory.glob("*.json"))) == 1 + assert store.counter_snapshot()[0]["packaged_value"] == 1 + + def test_concurrent_model_call_updates_are_transactional(tmp_path): database_path = tmp_path / "metrics.sqlite3" outbox_directory = tmp_path / "outbox" diff --git a/tests/hermes_cli/test_relay_shared_metrics_runtime.py b/tests/hermes_cli/test_relay_shared_metrics_runtime.py index ccda2abc1f4c..0005d8653e08 100644 --- a/tests/hermes_cli/test_relay_shared_metrics_runtime.py +++ b/tests/hermes_cli/test_relay_shared_metrics_runtime.py @@ -6,6 +6,7 @@ import contextvars import asyncio import json import threading +from datetime import datetime, timedelta, timezone from pathlib import Path from types import SimpleNamespace from typing import Any @@ -321,6 +322,7 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa def test_real_binding_drives_lifecycle_aggregation_export_and_snapshot( real_binding_runtime, tmp_path, + monkeypatch, ): assert real_binding_runtime._native is not None prompt_canary = "real-relay-sensitive-prompt" @@ -416,6 +418,12 @@ def test_real_binding_drives_lifecycle_aggregation_export_and_snapshot( root = tmp_path / "hermes-home" / "telemetry" / "shared_metrics" store = SharedMetricsStore(root / "metrics.sqlite3", root / "outbox") + tomorrow = datetime.now(timezone.utc) + timedelta(days=1) + monkeypatch.setattr( + "hermes_cli.observability.shared_metrics._utc_now", + lambda: tomorrow, + ) + assert len(store.create_and_export_package_if_due()) == 1 snapshot = store.counter_snapshot() by_metric: dict[str, list[dict[str, Any]]] = {} for counter in snapshot: @@ -451,7 +459,7 @@ def test_real_binding_drives_lifecycle_aggregation_export_and_snapshot( } package_values: dict[tuple[str, tuple[tuple[str, str], ...]], int] = {} packages = sorted((root / "outbox").glob("*.json")) - assert len(packages) == 3 + assert len(packages) == 2 package_payloads = [ json.loads(package.read_text(encoding="utf-8")) for package in packages ] @@ -2279,32 +2287,74 @@ def test_session_finalize_closes_a_pending_task_as_system_aborted(direct_runtime } -def test_sequential_tasks_in_one_session_aggregate_once_each(direct_runtime, tmp_path): +def test_desktop_task_completion_exports_once_per_utc_day( + direct_runtime, tmp_path, monkeypatch +): + current_time = datetime(2026, 7, 28, 9, tzinfo=timezone.utc) + monkeypatch.setattr( + "hermes_cli.observability.shared_metrics._utc_now", + lambda: current_time, + ) for task_id in ("t1", "t2"): lifecycle.invoke_hook( "pre_llm_call", session_id="s1", task_id=task_id, - platform="cli", + platform="desktop", ) lifecycle.invoke_hook( "on_session_end", session_id="s1", task_id=task_id, - platform="cli", + platform="desktop", completed=True, failed=False, interrupted=False, turn_exit_reason="text_response(stop)", ) - lifecycle.finalize_session(session_id="s1") outbox = tmp_path / "hermes-home" / "telemetry" / "shared_metrics" / "outbox" - [package_path] = list(outbox.glob("*.json")) - package = json.loads(package_path.read_text(encoding="utf-8")) - metrics = {metric["name"]: metric for metric in package["metrics"]} - assert metrics["hermes.task_run.started"]["value"] == 2 - assert metrics["hermes.task_run.finished"]["value"] == 2 + [first_package_path] = list(outbox.glob("*.json")) + first_package = json.loads(first_package_path.read_text(encoding="utf-8")) + first_metrics = {metric["name"]: metric for metric in first_package["metrics"]} + assert first_metrics["hermes.task_run.started"]["value"] == 1 + assert first_metrics["hermes.task_run.started"]["dimensions"] == { + "entrypoint": "interactive", + "execution_surface": "desktop", + } + assert first_metrics["hermes.task_run.finished"]["value"] == 1 + + lifecycle.finalize_session(session_id="s1") + assert list(outbox.glob("*.json")) == [first_package_path] + + current_time = datetime(2026, 7, 29, 9, tzinfo=timezone.utc) + lifecycle.invoke_hook( + "pre_llm_call", + session_id="s1", + task_id="t3", + platform="desktop", + ) + lifecycle.invoke_hook( + "on_session_end", + session_id="s1", + task_id="t3", + platform="desktop", + completed=True, + failed=False, + interrupted=False, + turn_exit_reason="text_response(stop)", + ) + + packages = [ + json.loads(package_path.read_text(encoding="utf-8")) + for package_path in outbox.glob("*.json") + ] + totals: dict[str, int] = {} + for package in packages: + for metric in package["metrics"]: + totals[metric["name"]] = totals.get(metric["name"], 0) + metric["value"] + assert totals["hermes.task_run.started"] == 3 + assert totals["hermes.task_run.finished"] == 3 def test_task_ownership_survives_session_id_rotation(direct_runtime): From 47cb4ea1fecc94cc8e1bff0177ff37f810ebe560 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 13:40:44 -0500 Subject: [PATCH 2/8] fix(desktop): allow camera capture through the permission handlers The session permission hooks were written for the voice composer and denied video outright, so any renderer getUserMedia({video}) failed with NotAllowedError before the OS was ever consulted. Rename isAudioCapturePermission to isMediaCapturePermission and accept video alongside audio in both the request and check handlers. The OS capture permission still applies, so the user keeps a real allow/deny. --- apps/desktop/electron/main.ts | 51 +++++++++++++++++------------------ 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index dfb4747f0392..81ddfe2f5bc8 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -5510,18 +5510,22 @@ function installContextMenu(window) { }) } -// Microphone capture for the voice composer. The renderer drives mic access +// Microphone and camera capture. The voice composer drives mic access and +// renderer features (e.g. desktop plugins) can drive camera access, both // through getUserMedia, which Chromium gates behind these two session hooks. // // The naive `details.mediaTypes.includes('audio')` check works on macOS but -// breaks on Windows: Chromium frequently fires the mic permission request with -// an empty/undefined `mediaTypes`, so the strict check denies it and -// getUserMedia throws NotAllowedError ("Microphone permission was denied"). -// We therefore treat an audio-capture request as allowed whenever it's the -// 'media'/'audioCapture' permission AND mediaTypes either includes 'audio' OR -// is empty/absent (the Windows case). Video is still denied. -function isAudioCapturePermission(permission, details) { - if (permission === 'audioCapture') { +// breaks on Windows: Chromium frequently fires the request with an empty or +// undefined `mediaTypes`, so a strict check denies it and getUserMedia throws +// NotAllowedError. We therefore allow the capture permissions and treat absent +// metadata as allowed. +// +// Granting here is not the last gate: the OS still applies its own capture +// permission (macOS TCC prompts on first use, per the NSMicrophone/NSCamera +// usage strings), so the user keeps a real allow/deny and can revoke it in +// System Settings afterwards. +function isMediaCapturePermission(permission, details) { + if (permission === 'audioCapture' || permission === 'videoCapture') { return true } @@ -5531,38 +5535,31 @@ function isAudioCapturePermission(permission, details) { const mediaTypes = details?.mediaTypes + // Windows: mediaTypes is often empty for a capture request. Don't deny on + // missing metadata. 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') + return mediaTypes.includes('audio') || mediaTypes.includes('video') } function installMediaPermissions() { // Async request handler: the prompt-style path (most platforms). session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback, details) => { - callback(isAudioCapturePermission(permission, details)) + callback(isMediaCapturePermission(permission, details)) }) // Synchronous check handler: Chromium consults this for getUserMedia on // Windows in addition to (or instead of) the request handler. Without it, - // the check defaults to false and the mic is denied before the request + // the check defaults to false and capture 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? */) { - // details.mediaType is a single string here (not the mediaTypes array). - const mediaType = details?.mediaType - - if (mediaType === 'video') { - return false - } - - return true - } - - return false + session.defaultSession.setPermissionCheckHandler((_webContents, permission) => { + return ( + permission === 'media' || + permission === ('audioCapture' as any) /* todo: is this needed? */ || + permission === ('videoCapture' as any) + ) }) } From eaf30a4de79ab1ec916bd7a5162353ec7787d5d4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 13:40:44 -0500 Subject: [PATCH 3/8] build(desktop): declare camera usage for signed macOS builds A hardened-runtime build needs the camera entitlement and an NSCameraUsageDescription string, or the packaged app is killed on first camera access instead of prompting. --- apps/desktop/electron/entitlements.mac.inherit.plist | 2 ++ apps/desktop/electron/entitlements.mac.plist | 2 ++ apps/desktop/package.json | 1 + 3 files changed, 5 insertions(+) diff --git a/apps/desktop/electron/entitlements.mac.inherit.plist b/apps/desktop/electron/entitlements.mac.inherit.plist index 53fdf0fc4370..a3defc5f8cfa 100644 --- a/apps/desktop/electron/entitlements.mac.inherit.plist +++ b/apps/desktop/electron/entitlements.mac.inherit.plist @@ -10,5 +10,7 @@ com.apple.security.device.audio-input + com.apple.security.device.camera + diff --git a/apps/desktop/electron/entitlements.mac.plist b/apps/desktop/electron/entitlements.mac.plist index 53fdf0fc4370..a3defc5f8cfa 100644 --- a/apps/desktop/electron/entitlements.mac.plist +++ b/apps/desktop/electron/entitlements.mac.plist @@ -10,5 +10,7 @@ com.apple.security.device.audio-input + com.apple.security.device.camera + diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 91e51e6fbf02..01f0e45eb7b0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -220,6 +220,7 @@ "CFBundleExecutable": "Hermes", "CFBundleName": "Hermes", "NSAudioCaptureUsageDescription": "Hermes uses audio capture for voice conversations.", + "NSCameraUsageDescription": "Hermes uses the camera when a plugin or feature you enable requests it.", "NSMicrophoneUsageDescription": "Hermes uses the microphone for voice input and voice conversations." }, "gatekeeperAssess": false, From 1c582c4c4a073e94fe1e4858901aff8a9441349d Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 28 Jul 2026 12:08:10 -0700 Subject: [PATCH 4/8] fix(observability): gate export on subscriber flush Signed-off-by: Alex Fournier --- .../observability/relay_shared_metrics.py | 25 ++++++-- .../test_relay_shared_metrics_runtime.py | 60 +++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/hermes_cli/observability/relay_shared_metrics.py b/hermes_cli/observability/relay_shared_metrics.py index 2274f8cd4df7..c35be04d697e 100644 --- a/hermes_cli/observability/relay_shared_metrics.py +++ b/hermes_cli/observability/relay_shared_metrics.py @@ -353,8 +353,15 @@ class _Runtime: return finished = self._finish_task(session, task_id, event) if finished: - self._safe(self.relay.subscribers.flush) - self._export() + try: + self.relay.subscribers.flush() + except Exception: + logger.warning( + "Hermes shared-metrics task flush failed", + exc_info=True, + ) + else: + self._export() def close_session(self, event: dict[str, Any]) -> None: session = self._session(event) @@ -383,7 +390,8 @@ class _Runtime: self.relay.subscribers.flush() except Exception as exc: failures.append(f"subscriber flush failed: {exc}") - self._export() + else: + self._export() with self._sessions_lock: if self._sessions.get(session.session_id) is session: self._sessions.pop(session.session_id, None) @@ -402,8 +410,15 @@ class _Runtime: self._safe(self.close_session, {"session_id": session_id}) if not self._registered: return - self._safe(self.relay.subscribers.flush) - self._export() + try: + self.relay.subscribers.flush() + except Exception: + logger.warning( + "Hermes shared-metrics shutdown flush failed", + exc_info=True, + ) + else: + self._export() self._safe(self.relay.subscribers.deregister, self._subscriber_name) self.host.release_managed_execution(self._subscriber_name) self._registered = False diff --git a/tests/hermes_cli/test_relay_shared_metrics_runtime.py b/tests/hermes_cli/test_relay_shared_metrics_runtime.py index 0005d8653e08..f1690dc39266 100644 --- a/tests/hermes_cli/test_relay_shared_metrics_runtime.py +++ b/tests/hermes_cli/test_relay_shared_metrics_runtime.py @@ -5,6 +5,7 @@ from __future__ import annotations import contextvars import asyncio import json +import sqlite3 import threading from datetime import datetime, timedelta, timezone from pathlib import Path @@ -2357,6 +2358,65 @@ def test_desktop_task_completion_exports_once_per_utc_day( assert totals["hermes.task_run.finished"] == 3 +def test_failed_flush_keeps_daily_export_open_for_later_task( + direct_runtime, tmp_path, monkeypatch, caplog +): + current_time = datetime(2026, 7, 28, 9, tzinfo=timezone.utc) + monkeypatch.setattr( + "hermes_cli.observability.shared_metrics._utc_now", + lambda: current_time, + ) + original_flush = direct_runtime.subscribers.flush + flush_attempts = 0 + + def fail_first_flush() -> None: + nonlocal flush_attempts + flush_attempts += 1 + if flush_attempts == 1: + raise RuntimeError("simulated flush failure") + original_flush() + + direct_runtime.subscribers.flush = fail_first_flush + + def finish_desktop_task(task_id: str) -> None: + lifecycle.invoke_hook( + "pre_llm_call", + session_id="s1", + task_id=task_id, + platform="desktop", + ) + lifecycle.invoke_hook( + "on_session_end", + session_id="s1", + task_id=task_id, + platform="desktop", + completed=True, + failed=False, + interrupted=False, + turn_exit_reason="text_response(stop)", + ) + + finish_desktop_task("t1") + + root = tmp_path / "hermes-home" / "telemetry" / "shared_metrics" + assert list((root / "outbox").glob("*.json")) == [] + with sqlite3.connect(root / "metrics.sqlite3") as connection: + [package_count] = connection.execute( + "SELECT COUNT(*) FROM package_outbox" + ).fetchone() + assert package_count == 0 + + finish_desktop_task("t2") + + [package_path] = list((root / "outbox").glob("*.json")) + package = json.loads(package_path.read_text(encoding="utf-8")) + metrics = {metric["name"]: metric for metric in package["metrics"]} + assert metrics["hermes.task_run.started"]["value"] == 2 + assert metrics["hermes.task_run.finished"]["value"] == 2 + assert flush_attempts == 2 + assert "Hermes shared-metrics task flush failed" in caplog.text + + def test_task_ownership_survives_session_id_rotation(direct_runtime): lifecycle.invoke_hook( "pre_llm_call", From d4221b27365a4bccaa6e864620991ad85701cf4f Mon Sep 17 00:00:00 2001 From: atakan g Date: Sat, 11 Jul 2026 18:12:25 +0300 Subject: [PATCH 5/8] fix(desktop): validate WebSocket token before backend ready --- apps/desktop/electron/main.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index b9e966067a38..ce2d09c8839e 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -8023,6 +8023,17 @@ async function spawnPoolBackend(profile, entry) { entry.token = authToken + // Verify the WebSocket session token before declaring backend ready. + // HTTP /api/status can pass while WS auth fails (separate transport, separate guards). + const wsUrl = `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}` + const wsProbe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket }) + + if (!wsProbe.ok) { + throw new Error( + `Hermes backend for profile "${profile}" is HTTP-reachable but the WebSocket (/api/ws) rejected the session token: ${wsProbe.reason}` + ) + } + return { baseUrl, mode: 'local', @@ -8030,7 +8041,7 @@ async function spawnPoolBackend(profile, entry) { authMode: 'token', token: authToken, profile, - wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`, + wsUrl, logs: hermesLog.slice(-80), ...getWindowState() } @@ -8348,6 +8359,16 @@ async function startHermes() { rememberLog }) + // Verify the WebSocket session token before declaring backend ready. + const wsUrl = `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}` + const wsProbe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket }) + + if (!wsProbe.ok) { + throw new Error( + `Local Hermes backend is HTTP-reachable but the WebSocket (/api/ws) rejected the session token: ${wsProbe.reason}` + ) + } + updateBootProgress({ phase: 'backend.ready', message: 'Hermes backend is ready. Finalizing desktop startup', @@ -8362,7 +8383,7 @@ async function startHermes() { source: 'local', authMode: 'token', token: authToken, - wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`, + wsUrl, logs: hermesLog.slice(-80), ...getWindowState() } From 15317e1fd7392646cd21542e7e30498a847d841e Mon Sep 17 00:00:00 2001 From: Atakan Date: Sat, 25 Jul 2026 21:42:31 +0300 Subject: [PATCH 6/8] fix(desktop): upload Windows attachments to Linux backends --- .../src/app/chat/session-tile-actions.ts | 7 +- .../hooks/use-prompt-actions/index.test.tsx | 115 +++++++++++++++++- .../session/hooks/use-prompt-actions/index.ts | 47 +++++-- .../thread/user-edit-composer.tsx | 2 +- 4 files changed, 155 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/app/chat/session-tile-actions.ts b/apps/desktop/src/app/chat/session-tile-actions.ts index 469691384598..16b47b995207 100644 --- a/apps/desktop/src/app/chat/session-tile-actions.ts +++ b/apps/desktop/src/app/chat/session-tile-actions.ts @@ -169,7 +169,12 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses } if (attachment.kind === 'image' || attachment.kind === 'file') { - const next = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }) + const next = await uploadComposerAttachment(attachment, { + backendCwd: readState()?.cwd, + remote, + requestGateway, + sessionId + }) if (options.updateComposerAttachments ?? true) { scope.attachments.update(next) 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 c1660530a035..290e631f4ee4 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 @@ -12,6 +12,7 @@ import { $notifications, clearNotifications } from '@/store/notifications' import { $busy, $connection, + $currentCwd, $currentUsage, $messages, $sessions, @@ -2128,6 +2129,7 @@ describe('usePromptActions file attachment sync', () => { afterEach(() => { cleanup() $connection.set(null) + $currentCwd.set('') vi.restoreAllMocks() }) @@ -2190,6 +2192,100 @@ describe('usePromptActions file attachment sync', () => { }) }) + + it('uploads Windows file bytes when local mode fronts a POSIX WSL/Docker backend', async () => { + $connection.set({ mode: 'local' } as never) + $currentCwd.set('/root') + const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,aGVsbG8=') + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { readFileDataUrl } + }) + + const attachment: ComposerAttachment = { + ...fileAttachment(), + path: 'C:\\Users\\alice\\Downloads\\report.txt', + refText: '@file:`C:\\Users\\alice\\Downloads\\report.txt`' + } + const calls: { method: string; params?: Record }[] = [] + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (method === 'file.attach') { + return { + attached: true, + path: '/root/.hermes/desktop-attachments/report.txt', + ref_text: '@file:.hermes/desktop-attachments/report.txt', + uploaded: true + } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> + ) + + expect(await handle!.submitText('summarize', { attachments: [attachment] })).toBe(true) + expect(readFileDataUrl).toHaveBeenCalledWith('C:\\Users\\alice\\Downloads\\report.txt') + expect(calls[0]).toEqual({ + method: 'file.attach', + params: { + data_url: 'data:text/plain;base64,aGVsbG8=', + name: 'report.txt', + path: 'C:\\Users\\alice\\Downloads\\report.txt', + session_id: RUNTIME_SESSION_ID + } + }) + expect(calls[1]).toEqual({ + method: 'prompt.submit', + params: { session_id: RUNTIME_SESSION_ID, text: '@file:.hermes/desktop-attachments/report.txt\n\nsummarize' } + }) + }) + + it('uses image.attach_bytes for a Windows image when the local backend cwd is POSIX', async () => { + const readFileDataUrl = vi.fn(async () => 'data:image/jpeg;base64,aGVsbG8=') + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { readFileDataUrl } + }) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'image.attach_bytes') { + return { attached: true, path: '/root/tmp/photo.jpg' } as never + } + + return {} as never + }) + + const uploaded = await uploadComposerAttachment( + { + id: 'image:photo.jpg', + kind: 'image', + label: 'photo.jpg', + path: 'C:\\Users\\alice\\Pictures\\photo.jpg' + }, + { + backendCwd: '/root', + remote: false, + requestGateway, + sessionId: RUNTIME_SESSION_ID + } + ) + + expect(readFileDataUrl).toHaveBeenCalledWith('C:\\Users\\alice\\Pictures\\photo.jpg') + expect(requestGateway).toHaveBeenCalledWith('image.attach_bytes', { + content_base64: 'aGVsbG8=', + filename: 'photo.jpg', + session_id: RUNTIME_SESSION_ID + }) + expect(requestGateway).not.toHaveBeenCalledWith('image.attach', expect.anything()) + expect(uploaded.path).toBe('/root/tmp/photo.jpg') + }) + it('passes a path-less @file: ref straight through (no path = nothing to upload)', async () => { // Submit-layer contract: only attachments that carry a `path` are upload // candidates. A path-less ref (an @-mention/context ref or pasted text) @@ -2237,8 +2333,20 @@ describe('usePromptActions file attachment sync', () => { expect(calls[0]?.params?.text).toContain('@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`') }) - it('passes the path directly via file.attach in local mode (no byte upload)', async () => { + it('passes a Windows path directly for a native Windows local backend', async () => { $connection.set({ mode: 'local' } as never) + $currentCwd.set('C:\\Users\\alice\\project') + const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,c2hvdWxkLW5vdC1iZS1yZWFk') + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { readFileDataUrl } + }) + + const attachment: ComposerAttachment = { + ...fileAttachment(), + path: 'C:\\Users\\alice\\Downloads\\report.txt', + refText: '@file:`C:\\Users\\alice\\Downloads\\report.txt`' + } const calls: { method: string; params?: Record }[] = [] @@ -2257,11 +2365,12 @@ describe('usePromptActions file attachment sync', () => { (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) - const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] }) + const ok = await handle!.submitText('summarize', { attachments: [attachment] }) expect(ok).toBe(true) expect(calls[0]?.method).toBe('file.attach') - // Local mode sends no data_url — the gateway shares this disk. + expect(readFileDataUrl).not.toHaveBeenCalled() + // Native Windows local mode shares the same path namespace. expect(calls[0]?.params).not.toHaveProperty('data_url') expect(calls[1]).toEqual({ method: 'prompt.submit', 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 681ef8d4199b..6307969421bf 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 @@ -25,6 +25,7 @@ import { clearAllPrompts } from '@/store/prompts' import { $busy, $connection, + $currentCwd, $messages, setAwaitingResponse, setBusy, @@ -76,26 +77,38 @@ interface HandoffResult { error?: string } +const WINDOWS_ABSOLUTE_PATH_RE = /^(?:[A-Za-z]:[\\/]|\\\\)/ +const POSIX_ABSOLUTE_PATH_RE = /^\/(?!\/)/ + +// `mode: local` means the gateway was launched locally, not necessarily that +// Electron and the gateway share a filesystem. Windows Desktop can front a +// WSL/Docker backend whose cwd is POSIX, so a Windows host path must cross the +// boundary as bytes just like a remote attachment. +function attachmentPathNeedsUpload(path: string, backendCwd?: null | string): boolean { + return WINDOWS_ABSOLUTE_PATH_RE.test(path.trim()) && POSIX_ABSOLUTE_PATH_RE.test(backendCwd?.trim() || '') +} + /** * Stage one file/image attachment into the session workspace and return the - * attachment rewritten with the gateway-side ref. Images upload their bytes in - * remote mode (so vision works) and pass the path locally; non-image files - * upload bytes remotely and pass the path locally. Throws on failure so callers - * can surface an error. Shared by submit-time sync, the eager drop-time upload, - * and the message-edit composer drop — keep them in lockstep. + * attachment rewritten with the gateway-side ref. Attachments upload their + * bytes for remote gateways and local cross-filesystem backends; otherwise the + * gateway receives the shared local path. Throws on failure so callers can + * surface an error. Shared by submit-time sync, the eager drop-time upload, and + * the message-edit composer drop — keep them in lockstep. */ export async function uploadComposerAttachment( attachment: ComposerAttachment, - opts: { remote: boolean; requestGateway: GatewayRequest; sessionId: string } + opts: { backendCwd?: null | string; remote: boolean; requestGateway: GatewayRequest; sessionId: string } ): Promise { - const { remote, requestGateway, sessionId } = opts + const { backendCwd, remote, requestGateway, sessionId } = opts const path = attachment.path ?? '' const label = attachment.label || pathLabel(path) + const uploadBytes = remote || attachmentPathNeedsUpload(path, backendCwd) if (attachment.kind === 'image') { let result: ImageAttachResponse - if (remote) { + if (uploadBytes) { let payload: Awaited> try { @@ -138,7 +151,7 @@ export async function uploadComposerAttachment( // Non-image file. let dataUrl: string | null = null - if (remote) { + if (uploadBytes) { try { dataUrl = await readFileDataUrlForAttach(path) } catch (err) { @@ -317,7 +330,12 @@ export function usePromptActions({ } if (attachment.kind === 'image' || attachment.kind === 'file') { - const nextAttachment = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }) + const nextAttachment = await uploadComposerAttachment(attachment, { + backendCwd: $currentCwd.get(), + remote, + requestGateway, + sessionId + }) // Update-only: never resurrect a chip the user removed mid-upload. if (updateComposerAttachments) { @@ -356,7 +374,14 @@ export function usePromptActions({ try { // Update-only: if the user removed the chip while this was uploading, // don't resurrect it — just drop the staged result on the floor. - updateComposerAttachment(await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })) + updateComposerAttachment( + await uploadComposerAttachment(attachment, { + backendCwd: $currentCwd.get(), + remote, + requestGateway, + sessionId + }) + ) } catch (err) { // Leave the chip in place so submit-time sync can retry (or the user can // remove it) and flag the card; also toast so a hard failure (unreadable diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index 52411d6bd3e9..bf8df4ea01e8 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -415,7 +415,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess try { const uploaded = await uploadComposerAttachment( { detail: path, id: attachmentId(kind, path), kind, label: pathLabel(path), path }, - { remote, requestGateway, sessionId } + { backendCwd: cwd, remote, requestGateway, sessionId } ) const ref = attachmentDisplayText(uploaded) From 89490ae373bbfa0990e1beab4c6d74ed728b2665 Mon Sep 17 00:00:00 2001 From: atakan g Date: Mon, 20 Jul 2026 00:12:00 +0300 Subject: [PATCH 7/8] fix(desktop): honor gitignore across Windows path casing --- .../src/app/right-sidebar/files/ipc.test.ts | 27 +++++++++++++++++++ .../src/app/right-sidebar/files/ipc.ts | 13 +++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/right-sidebar/files/ipc.test.ts b/apps/desktop/src/app/right-sidebar/files/ipc.test.ts index bcaddad55b5b..f4cc61b393ad 100644 --- a/apps/desktop/src/app/right-sidebar/files/ipc.test.ts +++ b/apps/desktop/src/app/right-sidebar/files/ipc.test.ts @@ -82,6 +82,33 @@ describe('readProjectDir', () => { expect(readFileDataUrl).toHaveBeenCalledWith('C:/repo/.gitignore') }) + it('filters gitignored entries when Windows path casing differs across IPC results', async () => { + gitRoot.mockResolvedValue('C:\\Repo') + readDir.mockImplementation(async path => { + if (path === 'c:\\repo\\src') { + return ok([ + { name: 'debug.log', path: 'c:\\repo\\src\\debug.log', isDirectory: false }, + { name: 'keep.ts', path: 'c:\\repo\\src\\keep.ts', isDirectory: false } + ]) + } + + if (path === 'C:/Repo') { + return ok([{ name: '.gitignore', path: 'C:/Repo/.gitignore', isDirectory: false }]) + } + + if (path === 'C:/Repo/src') { + return ok([]) + } + + return ok([]) + }) + readFileDataUrl.mockResolvedValue(dataUrl('src/*.log\n')) + + const result = await readProjectDir('c:\\repo\\src', 'c:\\repo') + + expect(result.entries.map(entry => entry.name)).toEqual(['keep.ts']) + }) + it('does not fetch .gitignore contents when listings do not contain .gitignore', async () => { gitRoot.mockResolvedValue('/repo') readDir.mockImplementation(async path => { diff --git a/apps/desktop/src/app/right-sidebar/files/ipc.ts b/apps/desktop/src/app/right-sidebar/files/ipc.ts index 7f60e4a20a1a..29fc46859255 100644 --- a/apps/desktop/src/app/right-sidebar/files/ipc.ts +++ b/apps/desktop/src/app/right-sidebar/files/ipc.ts @@ -32,16 +32,25 @@ function clean(path: string) { return path.replace(/\\/g, '/').replace(/\/+$/, '') || '/' } +// Windows path identity is case-insensitive. Fold only comparison keys so the +// relative path returned below keeps the filesystem's original spelling; +// POSIX paths remain case-sensitive. +function comparisonPath(path: string) { + return /^[A-Za-z]:(?:\/|$)/.test(path) || path.startsWith('//') ? path.toLowerCase() : path +} + /** Strict POSIX-style relative path; null if `child` is not inside `root`. */ function relativeTo(root: string, child: string) { const r = clean(root) const c = clean(child) + const rKey = comparisonPath(r) + const cKey = comparisonPath(c) - if (c === r) { + if (cKey === rKey) { return '' } - return c.startsWith(`${r}/`) ? c.slice(r.length + 1) : null + return cKey.startsWith(`${rKey}/`) ? c.slice(r.length + 1) : null } /** Repo-root → repo-root/a → repo-root/a/b → … for every dir between root and `dir`. */ From 64469bd2c86dc0abfd9ced326008a7cdd7c7e197 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:19:23 +0530 Subject: [PATCH 8/8] test(tts): accept stream kwarg in the 4 stale xAI fake_post mocks (#73619) _generate_xai_tts passes stream=True to requests.post since the TTS speaker pipeline moved to the streaming core. Four fake_post mocks in test_tts_xai_speech_tags.py still had the pre-streaming signature and raised TypeError: unexpected keyword argument 'stream'. The other 20+ mocks in the same file already accept stream=False; this aligns the stragglers. Fixes the 4 failures in Python tests slice 5/8 on main. --- tests/tools/test_tts_xai_speech_tags.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/tools/test_tts_xai_speech_tags.py b/tests/tools/test_tts_xai_speech_tags.py index 5559241bff62..a2e407a887a6 100644 --- a/tests/tools/test_tts_xai_speech_tags.py +++ b/tests/tools/test_tts_xai_speech_tags.py @@ -143,7 +143,7 @@ def test_generate_xai_tts_uses_oauth_pinned_base_url(tmp_path, monkeypatch): def raise_for_status(self): pass - def fake_post(url, headers, json, timeout): + def fake_post(url, headers, json, timeout, stream=False): captured["url"] = url captured["headers"] = headers return FakeResponse() @@ -590,7 +590,7 @@ def test_generate_xai_tts_omits_text_normalization_by_default(tmp_path, monkeypa fake_response.content = b"mp3" fake_response.raise_for_status.return_value = None - def fake_post(url, headers, json, timeout): + def fake_post(url, headers, json, timeout, stream=False): captured["json"] = json return fake_response @@ -614,7 +614,7 @@ def test_generate_xai_tts_sends_text_normalization_when_enabled(tmp_path, monkey fake_response.content = b"mp3" fake_response.raise_for_status.return_value = None - def fake_post(url, headers, json, timeout): + def fake_post(url, headers, json, timeout, stream=False): captured["json"] = json return fake_response @@ -640,7 +640,7 @@ def test_generate_xai_tts_omits_text_normalization_when_explicit_false( fake_response.content = b"mp3" fake_response.raise_for_status.return_value = None - def fake_post(url, headers, json, timeout): + def fake_post(url, headers, json, timeout, stream=False): captured["json"] = json return fake_response